@deftai/directive-content 0.58.0 → 0.60.0

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 (187) hide show
  1. package/.githooks/pre-push +10 -9
  2. package/Taskfile.yml +57 -67
  3. package/UPGRADING.md +1 -1
  4. package/docs/assets/directive-lifecycle-diagram.png +0 -0
  5. package/docs/directive-lifecycle.md +73 -0
  6. package/docs/getting-started.md +5 -1
  7. package/package.json +3 -3
  8. package/packs/rules/rules-pack-0.1.json +3 -3
  9. package/packs/skills/skills-pack-0.1.json +22 -22
  10. package/scm/github.md +20 -2
  11. package/tasks/change.yml +16 -31
  12. package/tasks/ci.yml +8 -0
  13. package/tasks/commit.yml +12 -19
  14. package/tasks/core.yml +10 -0
  15. package/tasks/engine.yml +42 -0
  16. package/tasks/framework.yml +3 -0
  17. package/tasks/install.yml +20 -19
  18. package/tasks/migrate.yml +26 -15
  19. package/tasks/project.yml +16 -0
  20. package/tasks/relocate.yml +18 -48
  21. package/tasks/toolchain.yml +15 -5
  22. package/tasks/vbrief.yml +4 -3
  23. package/tasks/verify.yml +12 -14
  24. package/templates/agents-entry.md +1 -2
  25. package/scripts/_agents_md.py +0 -494
  26. package/scripts/_cache_fetch.py +0 -635
  27. package/scripts/_cache_quota.py +0 -529
  28. package/scripts/_cache_refresh.py +0 -163
  29. package/scripts/_cache_validate.py +0 -209
  30. package/scripts/_content_root.py +0 -42
  31. package/scripts/_doctor_state.py +0 -277
  32. package/scripts/_event_detect.py +0 -305
  33. package/scripts/_events.py +0 -514
  34. package/scripts/_lifecycle_hygiene.py +0 -568
  35. package/scripts/_pathspec.py +0 -91
  36. package/scripts/_policy_show_cli.py +0 -266
  37. package/scripts/_precutover.py +0 -92
  38. package/scripts/_project_context.py +0 -224
  39. package/scripts/_project_definition_io.py +0 -164
  40. package/scripts/_relocate_snapshot.py +0 -209
  41. package/scripts/_relocate_states.py +0 -343
  42. package/scripts/_resolve_preflight_path.py +0 -152
  43. package/scripts/_safe_subprocess.py +0 -167
  44. package/scripts/_session_start_hook.py +0 -205
  45. package/scripts/_sor_gate_diff.py +0 -365
  46. package/scripts/_stdio_utf8.py +0 -59
  47. package/scripts/_triage_bootstrap_gitignore.py +0 -904
  48. package/scripts/_triage_classify_cli.py +0 -122
  49. package/scripts/_triage_queue_cli.py +0 -625
  50. package/scripts/_triage_scope_cli.py +0 -343
  51. package/scripts/_triage_scope_drift_cli.py +0 -121
  52. package/scripts/_triage_scope_ignores.py +0 -286
  53. package/scripts/_triage_scope_milestone.py +0 -432
  54. package/scripts/_triage_scope_mutations.py +0 -337
  55. package/scripts/_triage_scope_renderers.py +0 -207
  56. package/scripts/_triage_smoketest_stages.py +0 -674
  57. package/scripts/_triage_subscribe_cli.py +0 -140
  58. package/scripts/_triage_welcome_cli.py +0 -421
  59. package/scripts/_vbrief_build.py +0 -239
  60. package/scripts/_vbrief_fidelity.py +0 -479
  61. package/scripts/_vbrief_legacy.py +0 -589
  62. package/scripts/_vbrief_reconciliation.py +0 -883
  63. package/scripts/_vbrief_routing.py +0 -277
  64. package/scripts/_vbrief_safety.py +0 -778
  65. package/scripts/_vbrief_sources.py +0 -312
  66. package/scripts/_vbrief_speckit.py +0 -262
  67. package/scripts/_vbrief_story_quality.py +0 -353
  68. package/scripts/_vbrief_validation.py +0 -299
  69. package/scripts/build_dist.py +0 -412
  70. package/scripts/cache.py +0 -1078
  71. package/scripts/cache_scanner.py +0 -745
  72. package/scripts/candidates_log.py +0 -432
  73. package/scripts/capacity_backfill.py +0 -680
  74. package/scripts/capacity_show.py +0 -653
  75. package/scripts/ci_local.py +0 -689
  76. package/scripts/code_structure_validate.py +0 -765
  77. package/scripts/codebase_default_extractor.py +0 -495
  78. package/scripts/codebase_map.py +0 -304
  79. package/scripts/codebase_map_fresh.py +0 -104
  80. package/scripts/codebase_projection_registry.py +0 -94
  81. package/scripts/codebase_provider.py +0 -582
  82. package/scripts/doctor.py +0 -2551
  83. package/scripts/framework_commands.py +0 -505
  84. package/scripts/gh_rest.py +0 -882
  85. package/scripts/github_auth_modes.py +0 -437
  86. package/scripts/github_body.py +0 -292
  87. package/scripts/ip_risk.py +0 -531
  88. package/scripts/issue_emit.py +0 -670
  89. package/scripts/issue_ingest.py +0 -1064
  90. package/scripts/migrate_preflight.py +0 -418
  91. package/scripts/migrate_vbrief.py +0 -2677
  92. package/scripts/monitor_pr.py +0 -401
  93. package/scripts/pack_migrate_lessons.py +0 -336
  94. package/scripts/pack_migrate_patterns.py +0 -254
  95. package/scripts/pack_migrate_rules.py +0 -350
  96. package/scripts/pack_migrate_skills.py +0 -423
  97. package/scripts/pack_migrate_strategies.py +0 -311
  98. package/scripts/pack_migrate_swarm_spec.py +0 -250
  99. package/scripts/pack_render.py +0 -434
  100. package/scripts/packs_slice.py +0 -712
  101. package/scripts/platform_capabilities.py +0 -336
  102. package/scripts/policy.py +0 -2826
  103. package/scripts/policy_set.py +0 -324
  104. package/scripts/pr_check_closing_keywords.py +0 -524
  105. package/scripts/pr_check_protected_issues.py +0 -267
  106. package/scripts/pr_merge_readiness.py +0 -1004
  107. package/scripts/pr_wait_mergeable.py +0 -669
  108. package/scripts/prd_render.py +0 -159
  109. package/scripts/preflight_architecture_sor.py +0 -974
  110. package/scripts/preflight_branch.py +0 -289
  111. package/scripts/preflight_cache.py +0 -974
  112. package/scripts/preflight_gh.py +0 -721
  113. package/scripts/preflight_implementation.py +0 -272
  114. package/scripts/preflight_story_start.py +0 -838
  115. package/scripts/preflight_wip_cap.py +0 -149
  116. package/scripts/probe_session.py +0 -545
  117. package/scripts/project_render.py +0 -293
  118. package/scripts/quarantine_ext.py +0 -237
  119. package/scripts/reconcile_issues.py +0 -1442
  120. package/scripts/refresh-path.ps1 +0 -107
  121. package/scripts/release.py +0 -2030
  122. package/scripts/release_e2e.py +0 -1011
  123. package/scripts/release_publish.py +0 -486
  124. package/scripts/release_rollback.py +0 -980
  125. package/scripts/relocate.py +0 -1034
  126. package/scripts/resolve_changelog_unreleased.py +0 -667
  127. package/scripts/resolve_version.py +0 -490
  128. package/scripts/resume_conditions.py +0 -706
  129. package/scripts/ritual_sentinel.py +0 -609
  130. package/scripts/roadmap_render.py +0 -635
  131. package/scripts/rule_ownership_lint.py +0 -325
  132. package/scripts/scm.py +0 -591
  133. package/scripts/scope_audit_log.py +0 -387
  134. package/scripts/scope_decompose.py +0 -654
  135. package/scripts/scope_demote.py +0 -509
  136. package/scripts/scope_lifecycle.py +0 -1126
  137. package/scripts/scope_undo.py +0 -772
  138. package/scripts/session_start.py +0 -406
  139. package/scripts/setup_ghx.py +0 -339
  140. package/scripts/setup_windows.ps1 +0 -220
  141. package/scripts/slice_audit.py +0 -585
  142. package/scripts/slice_record.py +0 -530
  143. package/scripts/slice_record_existing.py +0 -692
  144. package/scripts/slug_normalize.py +0 -178
  145. package/scripts/spec_render.py +0 -477
  146. package/scripts/spec_validate.py +0 -238
  147. package/scripts/subagent_monitor.py +0 -658
  148. package/scripts/swarm_complete_cohort.py +0 -644
  149. package/scripts/swarm_launch.py +0 -1206
  150. package/scripts/swarm_readiness.py +0 -554
  151. package/scripts/swarm_verify_review_clean.py +0 -438
  152. package/scripts/swarm_worktrees.py +0 -497
  153. package/scripts/toolchain-check.py +0 -52
  154. package/scripts/triage_actions.py +0 -871
  155. package/scripts/triage_bootstrap.py +0 -1153
  156. package/scripts/triage_bulk.py +0 -630
  157. package/scripts/triage_classify.py +0 -932
  158. package/scripts/triage_help.py +0 -1685
  159. package/scripts/triage_queue.py +0 -1944
  160. package/scripts/triage_reconcile.py +0 -581
  161. package/scripts/triage_refresh.py +0 -643
  162. package/scripts/triage_scope.py +0 -999
  163. package/scripts/triage_scope_drift.py +0 -575
  164. package/scripts/triage_smoketest.py +0 -396
  165. package/scripts/triage_subscribe.py +0 -399
  166. package/scripts/triage_summary.py +0 -1011
  167. package/scripts/triage_welcome.py +0 -1178
  168. package/scripts/ts_check_lane.py +0 -86
  169. package/scripts/validate-links.py +0 -64
  170. package/scripts/validate_strategy_output.py +0 -212
  171. package/scripts/vbrief_activate.py +0 -228
  172. package/scripts/vbrief_migrate_conformance.py +0 -368
  173. package/scripts/vbrief_reconcile_graph.py +0 -306
  174. package/scripts/vbrief_reconcile_labels.py +0 -460
  175. package/scripts/vbrief_reconcile_umbrellas.py +0 -741
  176. package/scripts/vbrief_validate.py +0 -1144
  177. package/scripts/verify-stubs.py +0 -61
  178. package/scripts/verify_capacity.py +0 -160
  179. package/scripts/verify_encoding.py +0 -699
  180. package/scripts/verify_hooks_installed.py +0 -206
  181. package/scripts/verify_investigation.py +0 -360
  182. package/scripts/verify_judgment_gates.py +0 -827
  183. package/scripts/verify_no_task_runtime.py +0 -171
  184. package/scripts/verify_scm_boundary.py +0 -509
  185. package/scripts/verify_session_ritual.py +0 -389
  186. package/scripts/verify_tools.py +0 -426
  187. package/scripts/verify_vbrief_conformance.py +0 -478
@@ -1,164 +0,0 @@
1
- """Atomic PROJECT-DEFINITION read/write helpers (D14 / #1133).
2
-
3
- Shared by ``scripts/triage_subscribe.py`` and
4
- ``scripts/triage_scope_drift.py`` for the typed-policy mutation
5
- surface introduced by D14 (subscribe / unsubscribe / ignore verbs).
6
-
7
- Mirrors the atomic-write pattern in ``scripts/cache.py::_atomic_write_text``
8
- (tempfile + ``os.replace``) so a crash mid-write leaves the file
9
- untouched. The lifecycle file (``vbrief/PROJECT-DEFINITION.vbrief.json``)
10
- is the only file these helpers touch; the typed policy block lives at
11
- ``data["plan"]["policy"]``.
12
-
13
- Pure stdlib. No third-party dependencies.
14
- """
15
-
16
- from __future__ import annotations
17
-
18
- import contextlib
19
- import json
20
- import os
21
- import sys
22
- import tempfile
23
- import threading
24
- import time
25
- from collections.abc import Iterator
26
- from pathlib import Path
27
- from typing import Any
28
-
29
- _PROJECT_DEFINITION_REL_PATH = "vbrief/PROJECT-DEFINITION.vbrief.json"
30
- _mutation_thread_lock = threading.Lock()
31
-
32
-
33
- class ProjectDefinitionIOError(Exception):
34
- """Raised when the PROJECT-DEFINITION file is missing or malformed."""
35
-
36
-
37
- def project_definition_path(project_root: Path) -> Path:
38
- return project_root / _PROJECT_DEFINITION_REL_PATH
39
-
40
-
41
- @contextlib.contextmanager
42
- def project_definition_mutation_lock(project_root: Path) -> Iterator[None]:
43
- """Serialise PROJECT-DEFINITION read-modify-write critical sections.
44
-
45
- The sidecar ``<file>.lock`` is removed on exit -- on the happy path AND
46
- on an exception -- so a clean mutation never leaves
47
- ``vbrief/PROJECT-DEFINITION.vbrief.json.lock`` behind for ``git add -A``
48
- to trap on the next chore commit (#1311). The unlink runs in a
49
- ``finally`` while the in-process ``_mutation_thread_lock`` is still held,
50
- so no concurrent in-process acquirer can race it, and is best-effort: a
51
- cross-process holder (Windows keeps the open file locked) or a benign
52
- POSIX unlink race simply leaves the (gitignored) sidecar in place rather
53
- than raising.
54
- """
55
- path = project_definition_path(project_root)
56
- lock_path = path.parent / (path.name + ".lock")
57
- lock_path.parent.mkdir(parents=True, exist_ok=True)
58
- with _mutation_thread_lock:
59
- try:
60
- with open(lock_path, "a+b") as fh:
61
- if not lock_path.stat().st_size:
62
- fh.write(b"\0")
63
- fh.flush()
64
- fh.seek(0)
65
- if sys.platform == "win32":
66
- import msvcrt
67
-
68
- acquired = False
69
- deadline = time.monotonic() + 30.0
70
- while True:
71
- try:
72
- msvcrt.locking(fh.fileno(), msvcrt.LK_NBLCK, 1)
73
- acquired = True
74
- break
75
- except OSError:
76
- if time.monotonic() > deadline:
77
- raise
78
- time.sleep(0.02)
79
- try:
80
- yield
81
- finally:
82
- if acquired:
83
- fh.seek(0)
84
- with contextlib.suppress(OSError):
85
- msvcrt.locking(fh.fileno(), msvcrt.LK_UNLCK, 1)
86
- else:
87
- import fcntl
88
-
89
- fcntl.flock(fh.fileno(), fcntl.LOCK_EX)
90
- try:
91
- yield
92
- finally:
93
- fcntl.flock(fh.fileno(), fcntl.LOCK_UN)
94
- finally:
95
- # The sidecar handle is closed by the `with open(...)` block above
96
- # BEFORE this unlink (Windows refuses to delete an open file); the
97
- # unlink is held under _mutation_thread_lock so it cannot race an
98
- # in-process re-acquire, and is best-effort across processes
99
- # (#1311 -- do not leave PROJECT-DEFINITION.vbrief.json.lock behind).
100
- with contextlib.suppress(OSError):
101
- lock_path.unlink()
102
-
103
-
104
- def load_project_definition_for_mutation(
105
- project_root: Path,
106
- ) -> tuple[dict[str, Any], Path]:
107
- """Read PROJECT-DEFINITION.vbrief.json and return ``(data, path)``.
108
-
109
- Raises :class:`ProjectDefinitionIOError` if the file is missing or
110
- cannot be parsed as a JSON object. The returned dict is a mutable
111
- deep copy of the on-disk state; callers mutate it and pass it to
112
- :func:`atomic_write_project_definition` to persist.
113
- """
114
- path = project_definition_path(project_root)
115
- if not path.is_file():
116
- raise ProjectDefinitionIOError(
117
- f"PROJECT-DEFINITION not found at {path}; run task triage:welcome / "
118
- "task triage:bootstrap to scaffold one first."
119
- )
120
- try:
121
- raw = path.read_text(encoding="utf-8")
122
- except OSError as exc:
123
- raise ProjectDefinitionIOError(
124
- f"Could not read PROJECT-DEFINITION at {path}: {exc}"
125
- ) from exc
126
- try:
127
- data = json.loads(raw)
128
- except json.JSONDecodeError as exc:
129
- raise ProjectDefinitionIOError(
130
- f"PROJECT-DEFINITION at {path} is not valid JSON: {exc}"
131
- ) from exc
132
- if not isinstance(data, dict):
133
- raise ProjectDefinitionIOError(
134
- f"PROJECT-DEFINITION at {path} top-level value is not a JSON object"
135
- )
136
- return data, path
137
-
138
-
139
- def atomic_write_project_definition(path: Path, data: dict[str, Any]) -> None:
140
- """Atomically write ``data`` to ``path`` as pretty-printed JSON.
141
-
142
- Uses a tempfile + ``os.replace`` so the file is either fully
143
- written or completely unchanged. The parent directory is created
144
- on demand for first-write scenarios (fresh consumer installs).
145
- """
146
- path.parent.mkdir(parents=True, exist_ok=True)
147
- payload = json.dumps(data, indent=2, ensure_ascii=False, sort_keys=False)
148
- fd, tmp_name = tempfile.mkstemp(
149
- prefix=path.name + ".", suffix=".tmp", dir=str(path.parent)
150
- )
151
- tmp = Path(tmp_name)
152
- try:
153
- with os.fdopen(fd, "w", encoding="utf-8", newline="") as fh:
154
- fh.write(payload)
155
- if not payload.endswith("\n"):
156
- fh.write("\n")
157
- fh.flush()
158
- with contextlib.suppress(OSError):
159
- os.fsync(fh.fileno())
160
- os.replace(tmp, path)
161
- except BaseException:
162
- with contextlib.suppress(FileNotFoundError):
163
- tmp.unlink()
164
- raise
@@ -1,209 +0,0 @@
1
- """_relocate_snapshot.py -- snapshot tarball helpers for scripts/relocate.py (#992 PR2).
2
-
3
- Extracted from :mod:`scripts.relocate` to keep the parent under the deft
4
- 1000-line MUST limit. Mirrors the
5
- ``scripts/cache.py`` / ``scripts/_cache_validate.py`` /
6
- ``scripts/_cache_fetch.py`` split pattern.
7
-
8
- Public API:
9
-
10
- - :func:`create_snapshot` -- tar the consumer pre-relocate state.
11
- - :func:`extract_snapshot` -- untar a previously-written snapshot back.
12
- - :func:`latest_snapshot` -- newest snapshot in ``.deft-cache/``.
13
- - :func:`snapshot_path` -- conventional path for the next snapshot.
14
- - :func:`snapshot_dir` -- ``<project-root>/.deft-cache``.
15
- - :func:`utc_timestamp` -- ``YYYYMMDDTHHMMSSZ`` for snapshot filenames.
16
-
17
- The snapshot is gzip-compressed tar with members rooted at
18
- ``project_root`` so ``tar.extractall(project_root, filter='data')``
19
- restores them directly into place.
20
- """
21
-
22
- from __future__ import annotations
23
-
24
- import shutil
25
- import tarfile
26
- from datetime import UTC, datetime
27
- from pathlib import Path
28
-
29
- CANONICAL_FRAMEWORK_DIR: str = ".deft/core"
30
- LEGACY_FRAMEWORK_DIR: str = "deft"
31
- SNAPSHOT_PREFIX: str = "relocate-snapshot-"
32
-
33
- #: Project-relative paths the snapshot tarball is allowed to capture or
34
- #: leave residue for. The rollback path uses this set to clean any tracked
35
- #: path that was NOT captured in the snapshot (i.e. was created by the
36
- #: relocator post-snapshot) so the rollback restores a byte-equivalent
37
- #: pre-relocate state for tracked files. F3 fix (#1015): without this
38
- #: set the relocator-created ``.gitignore`` was left as residue after a
39
- #: rollback when the pre-relocate project had no ``.gitignore``.
40
- ROLLBACK_TRACKED_PATHS: tuple[str, ...] = (
41
- LEGACY_FRAMEWORK_DIR,
42
- CANONICAL_FRAMEWORK_DIR,
43
- "AGENTS.md",
44
- ".gitignore",
45
- )
46
-
47
-
48
- __all__ = [
49
- "CANONICAL_FRAMEWORK_DIR",
50
- "LEGACY_FRAMEWORK_DIR",
51
- "ROLLBACK_TRACKED_PATHS",
52
- "SNAPSHOT_PREFIX",
53
- "SnapshotError",
54
- "create_snapshot",
55
- "extract_snapshot",
56
- "latest_snapshot",
57
- "snapshot_dir",
58
- "snapshot_path",
59
- "utc_timestamp",
60
- ]
61
-
62
-
63
- class SnapshotError(RuntimeError):
64
- """Snapshot create / extract failure (raised with a descriptive message)."""
65
-
66
- def __init__(self, message: str, *, exit_code: int = 1) -> None:
67
- super().__init__(message)
68
- self.exit_code = exit_code
69
-
70
-
71
- def utc_timestamp() -> str:
72
- """Return ``YYYYMMDDTHHMMSSZ`` suitable for the snapshot filename."""
73
- return datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ")
74
-
75
-
76
- def snapshot_dir(project_root: Path) -> Path:
77
- return project_root / ".deft-cache"
78
-
79
-
80
- def snapshot_path(project_root: Path, *, timestamp: str | None = None) -> Path:
81
- stamp = timestamp or utc_timestamp()
82
- return snapshot_dir(project_root) / f"{SNAPSHOT_PREFIX}{stamp}.tar.gz"
83
-
84
-
85
- def latest_snapshot(project_root: Path) -> Path | None:
86
- sdir = snapshot_dir(project_root)
87
- if not sdir.is_dir():
88
- return None
89
- candidates = sorted(sdir.glob(f"{SNAPSHOT_PREFIX}*.tar.gz"))
90
- return candidates[-1] if candidates else None
91
-
92
-
93
- def create_snapshot(
94
- project_root: Path,
95
- *,
96
- target: Path | None = None,
97
- timestamp: str | None = None,
98
- ) -> Path:
99
- """Tarball the consumer's pre-relocate state into ``.deft-cache/``.
100
-
101
- Captures (when present): every path in :data:`ROLLBACK_TRACKED_PATHS`
102
- -- ``<project-root>/deft/``, ``<project-root>/.deft/core/``,
103
- ``<project-root>/AGENTS.md``, and ``<project-root>/.gitignore``. The
104
- tarball uses paths relative to ``project_root`` so
105
- :func:`extract_snapshot` restores them directly. Tracked paths that
106
- DID NOT exist pre-relocate are intentionally absent from the tarball;
107
- the rollback path uses that absence to recognise relocator-created
108
- residue and remove it (F3 fix #1015).
109
- """
110
- out = target or snapshot_path(project_root, timestamp=timestamp)
111
- out.parent.mkdir(parents=True, exist_ok=True)
112
- members = [project_root / name for name in ROLLBACK_TRACKED_PATHS]
113
- captured = [m for m in members if m.exists()]
114
- with tarfile.open(out, "w:gz") as tar:
115
- for member in captured:
116
- try:
117
- arcname = member.relative_to(project_root).as_posix()
118
- except ValueError:
119
- arcname = member.name
120
- tar.add(str(member), arcname=arcname, recursive=True)
121
- return out
122
-
123
-
124
- def extract_snapshot(
125
- project_root: Path,
126
- *,
127
- snapshot: Path | None = None,
128
- ) -> Path:
129
- """Extract ``snapshot`` (or the most recent) back into ``project_root``.
130
-
131
- Tracked-paths contract (F3 fix, #1015): every path in
132
- :data:`ROLLBACK_TRACKED_PATHS` is reset before extraction --
133
-
134
- - paths captured in the tarball are removed first (so a
135
- partially-relocated tree doesn't carry stale bytes forward) then
136
- re-extracted, and
137
- - paths NOT captured in the tarball are removed unconditionally
138
- because the relocator created them post-snapshot (e.g. a
139
- relocator-created ``.gitignore`` when the pre-relocate project had
140
- none). Without this step, ``git status --porcelain`` post-rollback
141
- would surface ``?? .gitignore`` and break the byte-equivalent
142
- pre-relocate-state contract.
143
-
144
- The ``.deft-cache/`` directory (which hosts the snapshot tarball
145
- itself) is intentionally NOT in the tracked-paths set -- removing it
146
- would break re-rollback against the same snapshot.
147
-
148
- Returns the snapshot path that was actually extracted (handy for the
149
- operator-facing log line).
150
- """
151
- chosen = snapshot or latest_snapshot(project_root)
152
- if chosen is None:
153
- raise SnapshotError(
154
- f"no snapshot found under {snapshot_dir(project_root)} -- "
155
- "rollback requires either --snapshot PATH or a prior wipe-and-reinstall"
156
- )
157
- if not chosen.is_file():
158
- raise SnapshotError(
159
- f"snapshot path {chosen} does not exist or is not a file",
160
- exit_code=2,
161
- )
162
-
163
- # F3 residue fix (#1015): clear EVERY tracked path before extracting
164
- # the tarball. Captured tracked paths are then restored by
165
- # ``extractall``; uncaptured tracked paths (relocator-created
166
- # post-snapshot residue, e.g. a fresh ``.gitignore`` written by
167
- # ``_ensure_gitignore_lines`` when the pre-relocate project had
168
- # none) stay absent so ``git status --porcelain`` is clean.
169
- # Symmetrically removing captured paths up front also guarantees no
170
- # partially-relocated bytes survive into the rolled-back state.
171
- for name in ROLLBACK_TRACKED_PATHS:
172
- target = project_root / name
173
- if target.is_dir() and not target.is_symlink():
174
- shutil.rmtree(target)
175
- elif target.is_file() or target.is_symlink():
176
- target.unlink()
177
-
178
- with tarfile.open(chosen, "r:gz") as tar:
179
- _safe_extract(tar, project_root)
180
- return chosen
181
-
182
-
183
- def _captured_top_level_names(snapshot: Path) -> set[str]:
184
- """Return the set of top-level paths captured in ``snapshot``.
185
-
186
- Used by :func:`extract_snapshot` to distinguish tracked paths that
187
- were captured (and thus will be restored by the tarball extract)
188
- from tracked paths that the relocator created post-snapshot (which
189
- must be removed without restoration to honour the byte-equivalent
190
- pre-relocate-state contract -- F3 fix #1015).
191
- """
192
- with tarfile.open(snapshot, "r:gz") as tar:
193
- names = tar.getnames()
194
- return {n.split("/", 1)[0] for n in names if n}
195
-
196
-
197
- def _safe_extract(tar: tarfile.TarFile, dest: Path) -> None:
198
- """Reject path traversal before extracting (per Python 3.12 best practice)."""
199
- dest_resolved = dest.resolve()
200
- for member in tar.getmembers():
201
- member_target = (dest / member.name).resolve()
202
- try:
203
- member_target.relative_to(dest_resolved)
204
- except ValueError:
205
- raise SnapshotError(
206
- f"snapshot member {member.name!r} would extract outside {dest}",
207
- exit_code=2,
208
- ) from None
209
- tar.extractall(dest, filter="data") # type: ignore[arg-type]