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.
Files changed (44) hide show
  1. patchshuttle/__init__.py +98 -0
  2. patchshuttle/_diff.py +317 -0
  3. patchshuttle/_process.py +198 -0
  4. patchshuttle/_version.py +3 -0
  5. patchshuttle/actions/__init__.py +80 -0
  6. patchshuttle/actions/constructors.py +211 -0
  7. patchshuttle/actions/create.py +155 -0
  8. patchshuttle/actions/modify.py +174 -0
  9. patchshuttle/audit.py +588 -0
  10. patchshuttle/backup.py +712 -0
  11. patchshuttle/checks/__init__.py +37 -0
  12. patchshuttle/checks/constructors.py +67 -0
  13. patchshuttle/checks/runner.py +233 -0
  14. patchshuttle/cli.py +766 -0
  15. patchshuttle/config.py +247 -0
  16. patchshuttle/context.py +370 -0
  17. patchshuttle/errors.py +291 -0
  18. patchshuttle/execution.py +651 -0
  19. patchshuttle/formatters/__init__.py +25 -0
  20. patchshuttle/formatters/runner.py +240 -0
  21. patchshuttle/identifiers.py +20 -0
  22. patchshuttle/inventory.py +331 -0
  23. patchshuttle/logging.py +741 -0
  24. patchshuttle/models.py +496 -0
  25. patchshuttle/operations.py +292 -0
  26. patchshuttle/parser.py +243 -0
  27. patchshuttle/planner.py +1144 -0
  28. patchshuttle/policy.py +377 -0
  29. patchshuttle/py.typed +1 -0
  30. patchshuttle/registry.py +275 -0
  31. patchshuttle/resources/AI_GUIDE.md +163 -0
  32. patchshuttle/resources/AUDIT-EXAMPLE.psh.yaml +10 -0
  33. patchshuttle/resources/PATCH-EXAMPLE.psh.yaml +17 -0
  34. patchshuttle/resources/PATCHSHUTTLE_PROTOCOL.md +109 -0
  35. patchshuttle/resources/__init__.py +1 -0
  36. patchshuttle/rollback.py +306 -0
  37. patchshuttle/runner.py +880 -0
  38. patchshuttle/verification.py +107 -0
  39. patchshuttle/workspace.py +382 -0
  40. patchshuttle-0.1.0a2.dist-info/METADATA +535 -0
  41. patchshuttle-0.1.0a2.dist-info/RECORD +44 -0
  42. patchshuttle-0.1.0a2.dist-info/WHEEL +4 -0
  43. patchshuttle-0.1.0a2.dist-info/entry_points.txt +2 -0
  44. patchshuttle-0.1.0a2.dist-info/licenses/LICENSE +21 -0
patchshuttle/runner.py ADDED
@@ -0,0 +1,880 @@
1
+ """Internal transaction runner for approved text-file change plans."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import stat
6
+ from collections.abc import Iterator
7
+ from contextlib import contextmanager
8
+ from dataclasses import dataclass, field
9
+ from enum import Enum
10
+ from pathlib import Path, PurePosixPath
11
+
12
+ from filelock import FileLock, Timeout
13
+
14
+ import patchshuttle.actions as actions
15
+ from patchshuttle.backup import (
16
+ BackupStatus,
17
+ PreparedBackup,
18
+ prepare_backup,
19
+ update_backup,
20
+ )
21
+ from patchshuttle.checks import CheckResult, CheckStatus, run_checks
22
+ from patchshuttle.errors import (
23
+ ExecutionError,
24
+ ExecutionErrorCode,
25
+ PlanningError,
26
+ PolicyError,
27
+ WorkspaceError,
28
+ )
29
+ from patchshuttle.formatters import (
30
+ FormattedFileState,
31
+ FormatterResult,
32
+ FormatterStatus,
33
+ capture_formatted_files,
34
+ run_formatters,
35
+ verify_formatted_files,
36
+ )
37
+ from patchshuttle.inventory import (
38
+ InventoryError,
39
+ WorkspaceComparison,
40
+ WorkspaceInventory,
41
+ capture_inventory,
42
+ compare_inventories,
43
+ )
44
+ from patchshuttle.models import JobKind
45
+ from patchshuttle.planner import (
46
+ ActionDisposition,
47
+ FileDisposition,
48
+ Plan,
49
+ plan_job,
50
+ )
51
+ from patchshuttle.rollback import rollback_created, rollback_transaction
52
+ from patchshuttle.workspace import Workspace
53
+
54
+ _CREATE_ACTIONS = frozenset({"create_directory", "create_file"})
55
+ _CHANGE_ACTIONS = frozenset(
56
+ {
57
+ *_CREATE_ACTIONS,
58
+ "replace_exact",
59
+ "insert_before",
60
+ "insert_after",
61
+ "delete_exact",
62
+ "apply_diff",
63
+ }
64
+ )
65
+
66
+
67
+ class TransactionStatus(str, Enum):
68
+ """Successful outcomes of the internal transaction core."""
69
+
70
+ APPLIED = "APPLIED"
71
+ NO_CHANGE = "NO_CHANGE"
72
+
73
+
74
+ @dataclass(frozen=True, slots=True)
75
+ class TransactionResult:
76
+ """Immutable result returned only after a verified transaction outcome."""
77
+
78
+ status: TransactionStatus
79
+ plan: Plan = field(repr=False)
80
+ backup_path: Path | None
81
+ created_files: tuple[PurePosixPath, ...]
82
+ created_directories: tuple[PurePosixPath, ...]
83
+ modified_files: tuple[PurePosixPath, ...] = ()
84
+ initial_checks: tuple[CheckResult, ...] = ()
85
+ formatting_results: tuple[FormatterResult, ...] = ()
86
+ formatted_files: tuple[FormattedFileState, ...] = ()
87
+ final_checks: tuple[CheckResult, ...] = ()
88
+ workspace_comparison: WorkspaceComparison | None = None
89
+
90
+
91
+ def execute_create_transaction(
92
+ plan: Plan,
93
+ *,
94
+ approved: bool = False,
95
+ keep_changes: bool = False,
96
+ ) -> TransactionResult:
97
+ """Apply only create actions through the retained Phase 8 contract."""
98
+
99
+ return _execute_transaction(
100
+ plan,
101
+ approved=approved,
102
+ keep_changes=keep_changes,
103
+ allowed_actions=_CREATE_ACTIONS,
104
+ allow_modify=False,
105
+ )
106
+
107
+
108
+ def execute_change_transaction(
109
+ plan: Plan,
110
+ *,
111
+ approved: bool = False,
112
+ keep_changes: bool = False,
113
+ ) -> TransactionResult:
114
+ """Apply all planned text changes under one guarded transaction."""
115
+
116
+ return _execute_transaction(
117
+ plan,
118
+ approved=approved,
119
+ keep_changes=keep_changes,
120
+ allowed_actions=_CHANGE_ACTIONS,
121
+ allow_modify=True,
122
+ )
123
+
124
+
125
+ def execute_change_transaction_locked(
126
+ plan: Plan,
127
+ *,
128
+ approved: bool = False,
129
+ keep_changes: bool = False,
130
+ ) -> TransactionResult:
131
+ """Execute a patch while the caller holds the workspace run lock.
132
+
133
+ This entry point exists for the public operational coordinator, which must
134
+ keep registry checks, project changes, logs, and archive updates inside one
135
+ lock boundary. Direct callers should use ``execute_change_transaction``.
136
+ """
137
+
138
+ _require_supported_plan(
139
+ plan,
140
+ allowed_actions=_CHANGE_ACTIONS,
141
+ allow_modify=True,
142
+ )
143
+ _require_approval(approved)
144
+ _require_keep_changes_allowed(plan, keep_changes)
145
+ return _execute_locked(plan, keep_changes=keep_changes)
146
+
147
+
148
+ def _execute_transaction(
149
+ plan: Plan,
150
+ *,
151
+ approved: bool,
152
+ keep_changes: bool,
153
+ allowed_actions: frozenset[str],
154
+ allow_modify: bool,
155
+ ) -> TransactionResult:
156
+ """Validate entry-point authority, acquire the lock, and execute."""
157
+
158
+ _require_supported_plan(
159
+ plan,
160
+ allowed_actions=allowed_actions,
161
+ allow_modify=allow_modify,
162
+ )
163
+ _require_approval(approved)
164
+ _require_keep_changes_allowed(plan, keep_changes)
165
+ with acquire_workspace_lock(plan.workspace):
166
+ return _execute_locked(plan, keep_changes=keep_changes)
167
+
168
+
169
+ def _require_approval(approved: bool) -> None:
170
+ if not approved:
171
+ raise ExecutionError(
172
+ ExecutionErrorCode.APPROVAL_REQUIRED,
173
+ "explicit approval is required before project files are changed",
174
+ )
175
+
176
+
177
+ def _require_keep_changes_allowed(plan: Plan, keep_changes: bool) -> None:
178
+ if keep_changes and not plan.workspace.config.execution.allow_keep_changes:
179
+ raise ExecutionError(
180
+ ExecutionErrorCode.KEEP_CHANGES_FORBIDDEN,
181
+ "local workspace policy does not allow keeping failed-job changes",
182
+ )
183
+
184
+
185
+ @contextmanager
186
+ def acquire_workspace_lock(workspace: Workspace) -> Iterator[None]:
187
+ """Acquire one initialized workspace's non-blocking operational lock."""
188
+
189
+ lock_path = workspace.patches_dir / "state/run.lock"
190
+ try:
191
+ lock_metadata = lock_path.lstat()
192
+ if not stat.S_ISREG(lock_metadata.st_mode):
193
+ raise OSError("workspace lock path is not a regular file")
194
+ except OSError as exc:
195
+ raise ExecutionError(
196
+ ExecutionErrorCode.WORKSPACE_LOCK_FAILED,
197
+ "workspace lock file is missing, unsafe, or unreadable",
198
+ path="patches/state/run.lock",
199
+ ) from exc
200
+ lock = FileLock(lock_path, timeout=0, preserve_lock_file=True)
201
+ try:
202
+ with lock.acquire(timeout=0):
203
+ yield
204
+ except Timeout as exc:
205
+ raise ExecutionError(
206
+ ExecutionErrorCode.WORKSPACE_LOCKED,
207
+ "another PatchShuttle transaction holds the workspace lock",
208
+ path="patches/state/run.lock",
209
+ ) from exc
210
+ except OSError as exc:
211
+ raise ExecutionError(
212
+ ExecutionErrorCode.WORKSPACE_LOCK_FAILED,
213
+ "workspace lock could not be acquired or released",
214
+ path="patches/state/run.lock",
215
+ ) from exc
216
+
217
+
218
+ def _require_supported_plan(
219
+ plan: Plan,
220
+ *,
221
+ allowed_actions: frozenset[str],
222
+ allow_modify: bool,
223
+ ) -> None:
224
+ if plan.job.kind is not JobKind.PATCH:
225
+ raise ExecutionError(
226
+ ExecutionErrorCode.JOB_KIND_UNSUPPORTED,
227
+ "the internal transaction core accepts only patch jobs",
228
+ )
229
+ for action in plan.actions:
230
+ if action.name not in allowed_actions:
231
+ raise ExecutionError(
232
+ ExecutionErrorCode.ACTION_UNSUPPORTED,
233
+ "this transaction entry point does not accept the planned action",
234
+ item_id=action.id,
235
+ path=(action.paths[0].as_posix() if action.paths else None),
236
+ )
237
+ if not allow_modify and any(
238
+ change.disposition is not FileDisposition.CREATE for change in plan.file_changes
239
+ ):
240
+ raise ExecutionError(
241
+ ExecutionErrorCode.ACTION_UNSUPPORTED,
242
+ "the create-only transaction cannot modify existing files",
243
+ )
244
+
245
+
246
+ def _execute_locked(plan: Plan, *, keep_changes: bool = False) -> TransactionResult:
247
+ _revalidate_plan(plan)
248
+ if not plan.file_changes and not plan.directories_to_create:
249
+ return TransactionResult(
250
+ status=TransactionStatus.NO_CHANGE,
251
+ plan=plan,
252
+ backup_path=None,
253
+ created_files=(),
254
+ created_directories=(),
255
+ modified_files=(),
256
+ initial_checks=(),
257
+ formatting_results=(),
258
+ formatted_files=(),
259
+ final_checks=(),
260
+ workspace_comparison=None,
261
+ )
262
+
263
+ baseline = _capture_workspace_inventory(plan)
264
+ backup = prepare_backup(plan)
265
+ created_files: list[PurePosixPath] = []
266
+ rollback_files: list[PurePosixPath] = []
267
+ created_directories: list[PurePosixPath] = []
268
+ modified_files: list[PurePosixPath] = []
269
+ applied_files: set[PurePosixPath] = set()
270
+ current_item: str | None = None
271
+ current_path: PurePosixPath | None = None
272
+ initial_checks: tuple[CheckResult, ...] = ()
273
+ formatting_results: tuple[FormatterResult, ...] = ()
274
+ formatted_files: tuple[FormattedFileState, ...] = ()
275
+ final_checks: tuple[CheckResult, ...] = ()
276
+ workspace_comparison: WorkspaceComparison | None = None
277
+ try:
278
+ changes = {change.path: change for change in plan.file_changes}
279
+ for action in plan.actions:
280
+ current_item = action.id
281
+ current_path = action.paths[0] if action.paths else None
282
+ if action.name == "create_directory":
283
+ if action.disposition is ActionDisposition.CREATE:
284
+ _create_required_directories(
285
+ plan,
286
+ action.paths[0],
287
+ created_directories,
288
+ )
289
+ continue
290
+ for path in action.paths:
291
+ current_path = path
292
+ change = changes.get(path)
293
+ if change is None or path in applied_files:
294
+ continue
295
+ if change.disposition is FileDisposition.CREATE:
296
+ _create_required_directories(
297
+ plan,
298
+ path.parent,
299
+ created_directories,
300
+ )
301
+ try:
302
+ actions.atomic_create_file(plan.workspace, change)
303
+ except actions.FilePublishError as exc:
304
+ if exc.target_created:
305
+ created_files.append(path)
306
+ rollback_files.append(path)
307
+ if exc.temporary_path is not None:
308
+ rollback_files.append(
309
+ PurePosixPath(
310
+ exc.temporary_path.relative_to(
311
+ plan.workspace.root
312
+ ).as_posix()
313
+ )
314
+ )
315
+ raise
316
+ created_files.append(path)
317
+ rollback_files.append(path)
318
+ actions.verify_created_file(plan.workspace, change)
319
+ else:
320
+ entry = backup.entry_for(path)
321
+ if entry.original_mode is None:
322
+ raise OSError("modified file backup is missing its mode")
323
+ try:
324
+ actions.atomic_replace_file(
325
+ plan.workspace,
326
+ change,
327
+ mode=entry.original_mode,
328
+ )
329
+ except actions.FileReplaceError as exc:
330
+ if exc.target_modified:
331
+ modified_files.append(path)
332
+ if exc.temporary_path is not None:
333
+ rollback_files.append(
334
+ PurePosixPath(
335
+ exc.temporary_path.relative_to(
336
+ plan.workspace.root
337
+ ).as_posix()
338
+ )
339
+ )
340
+ raise
341
+ modified_files.append(path)
342
+ actions.verify_modified_file(
343
+ plan.workspace,
344
+ change,
345
+ mode=entry.original_mode,
346
+ )
347
+ applied_files.add(path)
348
+
349
+ if tuple(created_directories) != plan.directories_to_create:
350
+ raise OSError("not all planned directories were created")
351
+ if tuple(created_files) != plan.files_to_create:
352
+ raise OSError("not all planned files were created")
353
+ if tuple(modified_files) != plan.files_to_modify:
354
+ raise OSError("not all planned files were modified")
355
+
356
+ current_item = None
357
+ current_path = None
358
+ check_run = run_checks(plan)
359
+ initial_checks = check_run.results
360
+ if check_run.failed is not None:
361
+ failure_messages = {
362
+ CheckStatus.FAILED: "initial project check returned a non-zero exit code",
363
+ CheckStatus.TIMED_OUT: "initial project check timed out",
364
+ CheckStatus.ERROR: "initial project check could not be started",
365
+ }
366
+ raise ExecutionError(
367
+ ExecutionErrorCode.CHECK_FAILED,
368
+ failure_messages[check_run.failed.status],
369
+ item_id=check_run.failed.id,
370
+ path=check_run.failed.name,
371
+ check_results=initial_checks,
372
+ )
373
+ _verify_transaction_files_after_checks(plan, backup, initial_checks)
374
+
375
+ if plan.formatting_targets:
376
+ current_item = "formatting"
377
+ current_path = plan.formatting_targets[0]
378
+ before_formatting = _capture_formatter_states(
379
+ plan,
380
+ check_results=initial_checks,
381
+ formatting_results=formatting_results,
382
+ message="formatter targets could not be captured before formatting",
383
+ )
384
+ try:
385
+ formatting_run = run_formatters(plan)
386
+ except (OSError, PolicyError, ValueError) as exc:
387
+ raise ExecutionError(
388
+ ExecutionErrorCode.FORMAT_FAILED,
389
+ "formatter commands could not be prepared or launched",
390
+ item_id="formatting",
391
+ path=plan.formatting_targets[0].as_posix(),
392
+ check_results=initial_checks,
393
+ formatting_results=formatting_results,
394
+ ) from exc
395
+ formatting_results = formatting_run.results
396
+ if formatting_run.failed is not None:
397
+ failure_messages = {
398
+ FormatterStatus.FAILED: ("formatter returned a non-zero exit code"),
399
+ FormatterStatus.TIMED_OUT: "formatter timed out",
400
+ FormatterStatus.ERROR: "formatter could not be started",
401
+ }
402
+ raise ExecutionError(
403
+ ExecutionErrorCode.FORMAT_FAILED,
404
+ failure_messages[formatting_run.failed.status],
405
+ item_id=formatting_run.failed.id,
406
+ path=formatting_run.failed.name,
407
+ check_results=initial_checks,
408
+ formatting_results=formatting_results,
409
+ )
410
+ _verify_planned_transaction_files(
411
+ plan,
412
+ backup,
413
+ excluded=frozenset(plan.formatting_targets),
414
+ code=ExecutionErrorCode.FORMAT_FAILED,
415
+ message="formatters changed a declared non-formatting file",
416
+ check_results=initial_checks,
417
+ formatting_results=formatting_results,
418
+ )
419
+ formatted_files = _capture_formatter_states(
420
+ plan,
421
+ check_results=initial_checks,
422
+ formatting_results=formatting_results,
423
+ message="formatter output has an invalid transaction state",
424
+ )
425
+ _require_preserved_formatter_modes(
426
+ before_formatting,
427
+ formatted_files,
428
+ check_results=initial_checks,
429
+ formatting_results=formatting_results,
430
+ )
431
+
432
+ if plan.workspace.config.formatting.rerun_checks:
433
+ current_item = None
434
+ current_path = None
435
+ final_run = run_checks(plan)
436
+ final_checks = final_run.results
437
+ all_checks = initial_checks + final_checks
438
+ if final_run.failed is not None:
439
+ failure_messages = {
440
+ CheckStatus.FAILED: (
441
+ "final project check returned a non-zero exit code"
442
+ ),
443
+ CheckStatus.TIMED_OUT: "final project check timed out",
444
+ CheckStatus.ERROR: "final project check could not be started",
445
+ }
446
+ raise ExecutionError(
447
+ ExecutionErrorCode.CHECK_FAILED,
448
+ failure_messages[final_run.failed.status],
449
+ item_id=final_run.failed.id,
450
+ path=final_run.failed.name,
451
+ check_results=all_checks,
452
+ formatting_results=formatting_results,
453
+ )
454
+ _verify_planned_transaction_files(
455
+ plan,
456
+ backup,
457
+ excluded=frozenset(plan.formatting_targets),
458
+ code=ExecutionErrorCode.CHECK_FAILED,
459
+ message="final project checks changed a declared transaction file",
460
+ check_results=all_checks,
461
+ formatting_results=formatting_results,
462
+ )
463
+ try:
464
+ verify_formatted_files(plan, formatted_files)
465
+ except (OSError, PolicyError, ValueError) as exc:
466
+ raise ExecutionError(
467
+ ExecutionErrorCode.CHECK_FAILED,
468
+ "final project checks changed a formatted transaction file",
469
+ path=_first_formatter_path(plan),
470
+ check_results=all_checks,
471
+ formatting_results=formatting_results,
472
+ ) from exc
473
+
474
+ current_item = None
475
+ current_path = None
476
+ workspace_comparison = _compare_workspace_to_baseline(plan, baseline)
477
+ if workspace_comparison.unexpected_changes:
478
+ unexpected = workspace_comparison.unexpected_changes[0]
479
+ raise ExecutionError(
480
+ ExecutionErrorCode.UNEXPECTED_WORKSPACE_CHANGE,
481
+ "project checks or formatters changed an undeclared workspace path",
482
+ path=unexpected.path.as_posix(),
483
+ check_results=initial_checks + final_checks,
484
+ formatting_results=formatting_results,
485
+ workspace_comparison=workspace_comparison,
486
+ )
487
+ try:
488
+ update_backup(
489
+ backup,
490
+ BackupStatus.COMPLETED,
491
+ capture_applied_state=True,
492
+ )
493
+ except ExecutionError as exc:
494
+ exc.check_results = initial_checks + final_checks
495
+ exc.formatting_results = formatting_results
496
+ raise
497
+ except BaseException as exc:
498
+ failure = _action_failure(
499
+ exc,
500
+ item_id=current_item,
501
+ path=current_path,
502
+ backup=backup,
503
+ )
504
+ if plan.auto_rollback and not keep_changes:
505
+ try:
506
+ _rollback_or_raise(
507
+ failure,
508
+ backup,
509
+ files=tuple(rollback_files),
510
+ directories=tuple(created_directories),
511
+ modified_files=tuple(modified_files),
512
+ )
513
+ except ExecutionError as rollback_failure:
514
+ rollback_failure.workspace_comparison = _safe_workspace_comparison(
515
+ plan,
516
+ baseline,
517
+ fallback=rollback_failure.workspace_comparison,
518
+ )
519
+ raise
520
+ else:
521
+ _record_retained_failure(
522
+ failure,
523
+ backup,
524
+ changes_present=bool(
525
+ rollback_files or created_directories or modified_files
526
+ ),
527
+ )
528
+ failure.workspace_comparison = _safe_workspace_comparison(
529
+ plan,
530
+ baseline,
531
+ fallback=failure.workspace_comparison,
532
+ )
533
+ if isinstance(exc, (KeyboardInterrupt, SystemExit)):
534
+ raise
535
+ raise failure
536
+
537
+ return TransactionResult(
538
+ status=TransactionStatus.APPLIED,
539
+ plan=plan,
540
+ backup_path=backup.path,
541
+ created_files=tuple(created_files),
542
+ created_directories=tuple(created_directories),
543
+ modified_files=tuple(modified_files),
544
+ initial_checks=initial_checks,
545
+ formatting_results=formatting_results,
546
+ formatted_files=formatted_files,
547
+ final_checks=final_checks,
548
+ workspace_comparison=workspace_comparison,
549
+ )
550
+
551
+
552
+ def _capture_workspace_inventory(plan: Plan) -> WorkspaceInventory:
553
+ try:
554
+ return capture_inventory(plan.workspace)
555
+ except InventoryError as exc:
556
+ raise ExecutionError(
557
+ ExecutionErrorCode.WORKSPACE_INVENTORY_FAILED,
558
+ "workspace baseline inventory could not be captured",
559
+ path=exc.path.as_posix() if exc.path is not None else None,
560
+ ) from exc
561
+
562
+
563
+ def _compare_workspace_to_baseline(
564
+ plan: Plan,
565
+ baseline: WorkspaceInventory,
566
+ ) -> WorkspaceComparison:
567
+ try:
568
+ current = capture_inventory(plan.workspace)
569
+ except InventoryError as exc:
570
+ raise ExecutionError(
571
+ ExecutionErrorCode.WORKSPACE_INVENTORY_FAILED,
572
+ "final workspace inventory could not be captured",
573
+ path=exc.path.as_posix() if exc.path is not None else None,
574
+ ) from exc
575
+ return compare_inventories(
576
+ baseline,
577
+ current,
578
+ expected_paths=(
579
+ *plan.files_to_create,
580
+ *plan.files_to_modify,
581
+ *plan.directories_to_create,
582
+ ),
583
+ )
584
+
585
+
586
+ def _safe_workspace_comparison(
587
+ plan: Plan,
588
+ baseline: WorkspaceInventory,
589
+ *,
590
+ fallback: WorkspaceComparison | None,
591
+ ) -> WorkspaceComparison | None:
592
+ try:
593
+ return _compare_workspace_to_baseline(plan, baseline)
594
+ except ExecutionError:
595
+ return fallback
596
+
597
+
598
+ def _revalidate_plan(plan: Plan) -> None:
599
+ try:
600
+ current = plan_job(plan.job, plan.workspace.root)
601
+ except (PlanningError, PolicyError, WorkspaceError) as exc:
602
+ raise ExecutionError(
603
+ ExecutionErrorCode.PLAN_STALE,
604
+ "the workspace no longer matches the approved plan",
605
+ item_id=getattr(exc, "item_id", None),
606
+ path=getattr(exc, "path", None),
607
+ ) from exc
608
+ if current != plan:
609
+ raise ExecutionError(
610
+ ExecutionErrorCode.PLAN_STALE,
611
+ "the workspace no longer matches the approved plan",
612
+ path=_first_stale_path(plan, current),
613
+ )
614
+
615
+
616
+ def _first_stale_path(plan: Plan, current: Plan) -> str | None:
617
+ current_creates = set(current.files_to_create) | set(current.directories_to_create)
618
+ for path in (*plan.files_to_create, *plan.directories_to_create):
619
+ if path not in current_creates:
620
+ return path.as_posix()
621
+ current_fingerprints = {item.path: item for item in current.fingerprints}
622
+ for fingerprint in plan.fingerprints:
623
+ if current_fingerprints.get(fingerprint.path) != fingerprint:
624
+ return fingerprint.path.as_posix()
625
+ return None
626
+
627
+
628
+ def _create_required_directories(
629
+ plan: Plan,
630
+ target: PurePosixPath,
631
+ created: list[PurePosixPath],
632
+ ) -> None:
633
+ for path in plan.directories_to_create:
634
+ if path in created:
635
+ continue
636
+ if path != target and path not in target.parents:
637
+ continue
638
+ actions.create_directory(plan.workspace, path)
639
+ created.append(path)
640
+ actions.verify_created_directory(plan.workspace, path)
641
+
642
+
643
+ def _verify_transaction_files_after_checks(
644
+ plan: Plan,
645
+ backup: PreparedBackup,
646
+ check_results: tuple[CheckResult, ...],
647
+ ) -> None:
648
+ _verify_planned_transaction_files(
649
+ plan,
650
+ backup,
651
+ excluded=frozenset(),
652
+ code=ExecutionErrorCode.CHECK_FAILED,
653
+ message="project checks changed a declared transaction file",
654
+ check_results=check_results,
655
+ formatting_results=(),
656
+ )
657
+
658
+
659
+ def _verify_planned_transaction_files(
660
+ plan: Plan,
661
+ backup: PreparedBackup,
662
+ *,
663
+ excluded: frozenset[PurePosixPath],
664
+ code: ExecutionErrorCode,
665
+ message: str,
666
+ check_results: tuple[CheckResult, ...],
667
+ formatting_results: tuple[FormatterResult, ...],
668
+ ) -> None:
669
+ for change in plan.file_changes:
670
+ if change.path in excluded:
671
+ continue
672
+ try:
673
+ if change.disposition is FileDisposition.CREATE:
674
+ actions.verify_created_file(plan.workspace, change)
675
+ else:
676
+ entry = backup.entry_for(change.path)
677
+ if entry.original_mode is None:
678
+ raise OSError("modified file backup is missing its mode")
679
+ actions.verify_modified_file(
680
+ plan.workspace,
681
+ change,
682
+ mode=entry.original_mode,
683
+ )
684
+ except (KeyError, OSError, PolicyError) as exc:
685
+ raise ExecutionError(
686
+ code,
687
+ message,
688
+ path=change.path.as_posix(),
689
+ check_results=check_results,
690
+ formatting_results=formatting_results,
691
+ ) from exc
692
+
693
+
694
+ def _capture_formatter_states(
695
+ plan: Plan,
696
+ *,
697
+ check_results: tuple[CheckResult, ...],
698
+ formatting_results: tuple[FormatterResult, ...],
699
+ message: str,
700
+ ) -> tuple[FormattedFileState, ...]:
701
+ try:
702
+ return capture_formatted_files(plan)
703
+ except (OSError, PolicyError, ValueError) as exc:
704
+ raise ExecutionError(
705
+ ExecutionErrorCode.FORMAT_FAILED,
706
+ message,
707
+ item_id="formatting",
708
+ path=_first_formatter_path(plan),
709
+ check_results=check_results,
710
+ formatting_results=formatting_results,
711
+ ) from exc
712
+
713
+
714
+ def _require_preserved_formatter_modes(
715
+ before: tuple[FormattedFileState, ...],
716
+ after: tuple[FormattedFileState, ...],
717
+ *,
718
+ check_results: tuple[CheckResult, ...],
719
+ formatting_results: tuple[FormatterResult, ...],
720
+ ) -> None:
721
+ if tuple(item.path for item in before) != tuple(item.path for item in after):
722
+ raise ExecutionError(
723
+ ExecutionErrorCode.FORMAT_FAILED,
724
+ "formatter output scope no longer matches the approved plan",
725
+ item_id="formatting",
726
+ path=before[0].path.as_posix() if before else None,
727
+ check_results=check_results,
728
+ formatting_results=formatting_results,
729
+ )
730
+ for earlier, later in zip(before, after):
731
+ if earlier.mode != later.mode:
732
+ raise ExecutionError(
733
+ ExecutionErrorCode.FORMAT_FAILED,
734
+ "formatter changed the mode of a transaction file",
735
+ item_id="formatting",
736
+ path=later.path.as_posix(),
737
+ check_results=check_results,
738
+ formatting_results=formatting_results,
739
+ )
740
+
741
+
742
+ def _first_formatter_path(plan: Plan) -> str | None:
743
+ return plan.formatting_targets[0].as_posix() if plan.formatting_targets else None
744
+
745
+
746
+ def _action_failure(
747
+ exc: BaseException,
748
+ *,
749
+ item_id: str | None,
750
+ path: PurePosixPath | None,
751
+ backup: PreparedBackup,
752
+ ) -> ExecutionError:
753
+ if isinstance(exc, ExecutionError):
754
+ return ExecutionError(
755
+ exc.code,
756
+ exc.message,
757
+ item_id=exc.item_id or item_id,
758
+ path=exc.path or (path.as_posix() if path is not None else None),
759
+ backup_path=backup.path,
760
+ rollback_skipped=exc.rollback_skipped,
761
+ changes_kept=exc.changes_kept,
762
+ cause_code=exc.cause_code,
763
+ check_results=exc.check_results,
764
+ formatting_results=exc.formatting_results,
765
+ workspace_comparison=exc.workspace_comparison,
766
+ )
767
+ return ExecutionError(
768
+ ExecutionErrorCode.ACTION_FAILED,
769
+ "a planned transaction action failed",
770
+ item_id=item_id,
771
+ path=path.as_posix() if path is not None else None,
772
+ backup_path=backup.path,
773
+ )
774
+
775
+
776
+ def _record_retained_failure(
777
+ failure: ExecutionError,
778
+ backup: PreparedBackup,
779
+ *,
780
+ changes_present: bool,
781
+ ) -> None:
782
+ failure.rollback_skipped = True
783
+ failure.changes_kept = changes_present
784
+ status = BackupStatus.CHANGES_KEPT if changes_present else BackupStatus.FAILED
785
+ try:
786
+ update_backup(
787
+ backup,
788
+ status,
789
+ failure_code=failure.code,
790
+ )
791
+ except ExecutionError as error:
792
+ error.cause_code = failure.code
793
+ error.check_results = failure.check_results
794
+ error.formatting_results = failure.formatting_results
795
+ error.rollback_skipped = True
796
+ error.changes_kept = changes_present
797
+ raise
798
+
799
+
800
+ def _rollback_or_raise(
801
+ failure: ExecutionError,
802
+ backup: PreparedBackup,
803
+ *,
804
+ files: tuple[PurePosixPath, ...],
805
+ directories: tuple[PurePosixPath, ...],
806
+ modified_files: tuple[PurePosixPath, ...],
807
+ ) -> None:
808
+ try:
809
+ if modified_files:
810
+ rollback = rollback_transaction(
811
+ backup.plan.workspace,
812
+ backup,
813
+ modified_files=modified_files,
814
+ files=files,
815
+ directories=directories,
816
+ )
817
+ else:
818
+ rollback = rollback_created(
819
+ backup.plan.workspace,
820
+ files=files,
821
+ directories=directories,
822
+ )
823
+ except BaseException as exc:
824
+ if isinstance(exc, (KeyboardInterrupt, SystemExit)):
825
+ raise
826
+ raise ExecutionError(
827
+ ExecutionErrorCode.ROLLBACK_FAILED,
828
+ "rollback failed before it could report a complete result",
829
+ backup_path=backup.path,
830
+ rollback_succeeded=False,
831
+ cause_code=failure.code,
832
+ check_results=failure.check_results,
833
+ formatting_results=failure.formatting_results,
834
+ workspace_comparison=failure.workspace_comparison,
835
+ ) from exc
836
+ if rollback.success:
837
+ try:
838
+ update_backup(
839
+ backup,
840
+ BackupStatus.ROLLED_BACK,
841
+ failure_code=failure.code,
842
+ )
843
+ except ExecutionError as exc:
844
+ exc.rollback_succeeded = True
845
+ exc.cause_code = failure.code
846
+ exc.check_results = failure.check_results
847
+ exc.formatting_results = failure.formatting_results
848
+ raise
849
+ failure.rollback_succeeded = True
850
+ return
851
+
852
+ try:
853
+ update_backup(
854
+ backup,
855
+ BackupStatus.ROLLBACK_FAILED,
856
+ failure_code=failure.code,
857
+ )
858
+ except ExecutionError:
859
+ pass
860
+ raise ExecutionError(
861
+ ExecutionErrorCode.ROLLBACK_FAILED,
862
+ "rollback could not restore every created path",
863
+ path=rollback.unresolved[0].as_posix(),
864
+ backup_path=backup.path,
865
+ rollback_succeeded=False,
866
+ cause_code=failure.code,
867
+ check_results=failure.check_results,
868
+ formatting_results=failure.formatting_results,
869
+ workspace_comparison=failure.workspace_comparison,
870
+ )
871
+
872
+
873
+ __all__ = [
874
+ "TransactionResult",
875
+ "TransactionStatus",
876
+ "acquire_workspace_lock",
877
+ "execute_change_transaction",
878
+ "execute_change_transaction_locked",
879
+ "execute_create_transaction",
880
+ ]