@deftai/directive-content 0.55.1 → 0.56.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.
- package/.githooks/pre-commit +143 -0
- package/.githooks/pre-push +121 -0
- package/QUICK-START.md +13 -3
- package/Taskfile.yml +934 -0
- package/UPGRADING.md +82 -11
- package/events/README.md +3 -3
- package/package.json +5 -4
- package/packs/skills/skills-pack-0.1.json +22 -22
- package/scripts/_agents_md.py +494 -0
- package/scripts/_cache_fetch.py +635 -0
- package/scripts/_cache_quota.py +529 -0
- package/scripts/_cache_refresh.py +163 -0
- package/scripts/_cache_validate.py +209 -0
- package/scripts/_content_root.py +42 -0
- package/scripts/_doctor_state.py +277 -0
- package/scripts/_event_detect.py +305 -0
- package/scripts/_events.py +514 -0
- package/scripts/_lifecycle_hygiene.py +568 -0
- package/scripts/_pathspec.py +91 -0
- package/scripts/_policy_show_cli.py +266 -0
- package/scripts/_precutover.py +92 -0
- package/scripts/_project_context.py +224 -0
- package/scripts/_project_definition_io.py +164 -0
- package/scripts/_relocate_snapshot.py +209 -0
- package/scripts/_relocate_states.py +343 -0
- package/scripts/_resolve_preflight_path.py +152 -0
- package/scripts/_safe_subprocess.py +167 -0
- package/scripts/_session_start_hook.py +205 -0
- package/scripts/_sor_gate_diff.py +365 -0
- package/scripts/_stdio_utf8.py +59 -0
- package/scripts/_triage_bootstrap_gitignore.py +904 -0
- package/scripts/_triage_classify_cli.py +122 -0
- package/scripts/_triage_queue_cli.py +625 -0
- package/scripts/_triage_scope_cli.py +343 -0
- package/scripts/_triage_scope_drift_cli.py +121 -0
- package/scripts/_triage_scope_ignores.py +286 -0
- package/scripts/_triage_scope_milestone.py +432 -0
- package/scripts/_triage_scope_mutations.py +337 -0
- package/scripts/_triage_scope_renderers.py +207 -0
- package/scripts/_triage_smoketest_stages.py +674 -0
- package/scripts/_triage_subscribe_cli.py +140 -0
- package/scripts/_triage_welcome_cli.py +421 -0
- package/scripts/_vbrief_build.py +239 -0
- package/scripts/_vbrief_fidelity.py +479 -0
- package/scripts/_vbrief_legacy.py +589 -0
- package/scripts/_vbrief_reconciliation.py +883 -0
- package/scripts/_vbrief_routing.py +277 -0
- package/scripts/_vbrief_safety.py +778 -0
- package/scripts/_vbrief_sources.py +312 -0
- package/scripts/_vbrief_speckit.py +262 -0
- package/scripts/_vbrief_story_quality.py +353 -0
- package/scripts/_vbrief_validation.py +299 -0
- package/scripts/build_dist.py +412 -0
- package/scripts/cache.py +1078 -0
- package/scripts/cache_scanner.py +745 -0
- package/scripts/candidates_log.py +432 -0
- package/scripts/capacity_backfill.py +680 -0
- package/scripts/capacity_show.py +653 -0
- package/scripts/ci_local.py +689 -0
- package/scripts/code_structure_validate.py +765 -0
- package/scripts/codebase_default_extractor.py +495 -0
- package/scripts/codebase_map.py +304 -0
- package/scripts/codebase_map_fresh.py +104 -0
- package/scripts/codebase_projection_registry.py +94 -0
- package/scripts/codebase_provider.py +582 -0
- package/scripts/doctor.py +2257 -0
- package/scripts/framework_commands.py +505 -0
- package/scripts/gh_rest.py +882 -0
- package/scripts/github_auth_modes.py +437 -0
- package/scripts/github_body.py +292 -0
- package/scripts/ip_risk.py +531 -0
- package/scripts/issue_emit.py +670 -0
- package/scripts/issue_ingest.py +1064 -0
- package/scripts/migrate_preflight.py +418 -0
- package/scripts/migrate_vbrief.py +2677 -0
- package/scripts/monitor_pr.py +401 -0
- package/scripts/pack_migrate_lessons.py +336 -0
- package/scripts/pack_migrate_patterns.py +254 -0
- package/scripts/pack_migrate_rules.py +350 -0
- package/scripts/pack_migrate_skills.py +423 -0
- package/scripts/pack_migrate_strategies.py +311 -0
- package/scripts/pack_migrate_swarm_spec.py +250 -0
- package/scripts/pack_render.py +434 -0
- package/scripts/packs_slice.py +712 -0
- package/scripts/platform_capabilities.py +336 -0
- package/scripts/policy.py +2826 -0
- package/scripts/policy_set.py +324 -0
- package/scripts/pr_check_closing_keywords.py +524 -0
- package/scripts/pr_check_protected_issues.py +267 -0
- package/scripts/pr_merge_readiness.py +1004 -0
- package/scripts/pr_wait_mergeable.py +669 -0
- package/scripts/prd_render.py +159 -0
- package/scripts/preflight_architecture_sor.py +974 -0
- package/scripts/preflight_branch.py +289 -0
- package/scripts/preflight_cache.py +974 -0
- package/scripts/preflight_gh.py +721 -0
- package/scripts/preflight_implementation.py +272 -0
- package/scripts/preflight_story_start.py +838 -0
- package/scripts/preflight_wip_cap.py +149 -0
- package/scripts/probe_session.py +545 -0
- package/scripts/project_render.py +293 -0
- package/scripts/quarantine_ext.py +237 -0
- package/scripts/reconcile_issues.py +1442 -0
- package/scripts/refresh-path.ps1 +107 -0
- package/scripts/release.py +2030 -0
- package/scripts/release_e2e.py +1011 -0
- package/scripts/release_publish.py +486 -0
- package/scripts/release_rollback.py +980 -0
- package/scripts/relocate.py +1034 -0
- package/scripts/resolve_changelog_unreleased.py +667 -0
- package/scripts/resolve_version.py +490 -0
- package/scripts/resume_conditions.py +706 -0
- package/scripts/ritual_sentinel.py +609 -0
- package/scripts/roadmap_render.py +635 -0
- package/scripts/rule_ownership_lint.py +325 -0
- package/scripts/scm.py +591 -0
- package/scripts/scope_audit_log.py +387 -0
- package/scripts/scope_decompose.py +654 -0
- package/scripts/scope_demote.py +509 -0
- package/scripts/scope_lifecycle.py +1126 -0
- package/scripts/scope_undo.py +772 -0
- package/scripts/session_start.py +406 -0
- package/scripts/setup_ghx.py +339 -0
- package/scripts/setup_windows.ps1 +220 -0
- package/scripts/slice_audit.py +585 -0
- package/scripts/slice_record.py +530 -0
- package/scripts/slice_record_existing.py +692 -0
- package/scripts/slug_normalize.py +178 -0
- package/scripts/spec_render.py +477 -0
- package/scripts/spec_validate.py +238 -0
- package/scripts/subagent_monitor.py +658 -0
- package/scripts/swarm_complete_cohort.py +644 -0
- package/scripts/swarm_launch.py +1206 -0
- package/scripts/swarm_readiness.py +554 -0
- package/scripts/swarm_verify_review_clean.py +438 -0
- package/scripts/swarm_worktrees.py +497 -0
- package/scripts/toolchain-check.py +52 -0
- package/scripts/triage_actions.py +871 -0
- package/scripts/triage_bootstrap.py +1153 -0
- package/scripts/triage_bulk.py +630 -0
- package/scripts/triage_classify.py +932 -0
- package/scripts/triage_help.py +1685 -0
- package/scripts/triage_queue.py +1944 -0
- package/scripts/triage_reconcile.py +581 -0
- package/scripts/triage_refresh.py +643 -0
- package/scripts/triage_scope.py +999 -0
- package/scripts/triage_scope_drift.py +575 -0
- package/scripts/triage_smoketest.py +396 -0
- package/scripts/triage_subscribe.py +399 -0
- package/scripts/triage_summary.py +1011 -0
- package/scripts/triage_welcome.py +1178 -0
- package/scripts/ts_check_lane.py +86 -0
- package/scripts/validate-links.py +64 -0
- package/scripts/validate_strategy_output.py +212 -0
- package/scripts/vbrief_activate.py +228 -0
- package/scripts/vbrief_migrate_conformance.py +368 -0
- package/scripts/vbrief_reconcile_graph.py +306 -0
- package/scripts/vbrief_reconcile_labels.py +460 -0
- package/scripts/vbrief_reconcile_umbrellas.py +741 -0
- package/scripts/vbrief_validate.py +1195 -0
- package/scripts/verify-stubs.py +61 -0
- package/scripts/verify_capacity.py +160 -0
- package/scripts/verify_encoding.py +699 -0
- package/scripts/verify_hooks_installed.py +206 -0
- package/scripts/verify_investigation.py +360 -0
- package/scripts/verify_judgment_gates.py +827 -0
- package/scripts/verify_no_task_runtime.py +171 -0
- package/scripts/verify_scm_boundary.py +509 -0
- package/scripts/verify_session_ritual.py +389 -0
- package/scripts/verify_tools.py +426 -0
- package/scripts/verify_vbrief_conformance.py +478 -0
- package/skills/deft-directive-swarm/SKILL.md +7 -26
- package/skills/deft-directive-sync/SKILL.md +1 -1
- package/tasks/architecture.yml +13 -0
- package/tasks/cache.yml +69 -0
- package/tasks/capacity.yml +38 -0
- package/tasks/change.yml +46 -0
- package/tasks/changelog.yml +24 -0
- package/tasks/ci.yml +49 -0
- package/tasks/codebase.yml +47 -0
- package/tasks/commit.yml +30 -0
- package/tasks/core.yml +126 -0
- package/tasks/deployments.yml +54 -0
- package/tasks/framework.yml +74 -0
- package/tasks/install.yml +60 -0
- package/tasks/issue.yml +50 -0
- package/tasks/migrate.yml +73 -0
- package/tasks/packs.yml +92 -0
- package/tasks/policy.yml +75 -0
- package/tasks/pr.yml +89 -0
- package/tasks/prd.yml +39 -0
- package/tasks/project.yml +27 -0
- package/tasks/reconcile.yml +32 -0
- package/tasks/relocate.yml +56 -0
- package/tasks/roadmap.yml +28 -0
- package/tasks/scm.yml +126 -0
- package/tasks/scope-undo.yml +36 -0
- package/tasks/scope.yml +141 -0
- package/tasks/session.yml +19 -0
- package/tasks/setup.yml +37 -0
- package/tasks/slice.yml +69 -0
- package/tasks/spec.yml +41 -0
- package/tasks/swarm.yml +85 -0
- package/tasks/toolchain.yml +13 -0
- package/tasks/triage-actions.yml +94 -0
- package/tasks/triage-bootstrap.yml +43 -0
- package/tasks/triage-bulk.yml +75 -0
- package/tasks/triage-classify.yml +30 -0
- package/tasks/triage-queue.yml +50 -0
- package/tasks/triage-reconcile.yml +29 -0
- package/tasks/triage-scope-drift.yml +29 -0
- package/tasks/triage-scope.yml +31 -0
- package/tasks/triage-smoketest.yml +33 -0
- package/tasks/triage-subscribe.yml +36 -0
- package/tasks/triage-summary.yml +29 -0
- package/tasks/triage-welcome.yml +32 -0
- package/tasks/ts.yml +328 -0
- package/tasks/vbrief.yml +206 -0
- package/tasks/verify.yml +292 -0
- package/templates/agents-entry.md +2 -2
|
@@ -0,0 +1,778 @@
|
|
|
1
|
+
"""
|
|
2
|
+
_vbrief_safety.py -- Safety helpers for `task migrate:vbrief` (#497, #506 D7).
|
|
3
|
+
|
|
4
|
+
# --- safety (Agent C, #497) ---
|
|
5
|
+
|
|
6
|
+
Implements the four safety affordances for the destructive-default migrator:
|
|
7
|
+
|
|
8
|
+
1. Always-on `.premigrate.*` backups of every pre-cutover input before any
|
|
9
|
+
destructive write (#497-1).
|
|
10
|
+
2. `--dry-run` preview that produces the migration plan without touching the
|
|
11
|
+
filesystem (#497-2). Implemented via a `dry_run` flag threaded through the
|
|
12
|
+
migration entry point; this module contributes the guard helpers.
|
|
13
|
+
3. Dirty-tree guard: refuses to run on a non-clean `git status --porcelain`
|
|
14
|
+
unless the caller passes `--force` (#497-3).
|
|
15
|
+
4. `--rollback` path: restores from `.premigrate.*` backups and removes the
|
|
16
|
+
scope vBRIEFs / migration artefacts that a prior run created (#497-4),
|
|
17
|
+
using a manifest written by the migrator at the end of a successful run.
|
|
18
|
+
|
|
19
|
+
Coordinates with #498 (validation failure keeps backups + writes partial
|
|
20
|
+
output to `vbrief.invalid/`) -- that scope is owned by Agent D; nothing in
|
|
21
|
+
this module should ever delete a `.premigrate.*` file outside of the
|
|
22
|
+
`rollback()` path.
|
|
23
|
+
|
|
24
|
+
Source of truth for the decisions above is tracking issue #506.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import hashlib
|
|
30
|
+
import json
|
|
31
|
+
import shutil
|
|
32
|
+
import subprocess
|
|
33
|
+
from collections.abc import Callable, Iterable
|
|
34
|
+
from dataclasses import asdict, dataclass, field
|
|
35
|
+
from datetime import UTC, datetime
|
|
36
|
+
from pathlib import Path
|
|
37
|
+
|
|
38
|
+
# ---------------------------------------------------------------------------
|
|
39
|
+
# Filesystem constants
|
|
40
|
+
# ---------------------------------------------------------------------------
|
|
41
|
+
|
|
42
|
+
PREMIGRATE_SUFFIX = ".premigrate"
|
|
43
|
+
"""Infix written between the filename stem and its extension for backups.
|
|
44
|
+
|
|
45
|
+
Example: ``SPECIFICATION.md`` -> ``SPECIFICATION.premigrate.md``;
|
|
46
|
+
``specification.vbrief.json`` -> ``specification.premigrate.vbrief.json``.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
SAFETY_MANIFEST_NAME = "safety-manifest.json"
|
|
50
|
+
"""Manifest filename under ``vbrief/migration/`` that records per-run state."""
|
|
51
|
+
|
|
52
|
+
MIGRATION_DIR = "migration"
|
|
53
|
+
"""Subdirectory of ``vbrief/`` where migration-report artefacts live (#506)."""
|
|
54
|
+
|
|
55
|
+
LEGACY_DIR = "legacy"
|
|
56
|
+
"""Subdirectory of ``vbrief/`` where oversize legacy captures spill (#505)."""
|
|
57
|
+
|
|
58
|
+
# The four project-root markdown inputs and one JSON input the migrator
|
|
59
|
+
# consumes. PRD.md is optional -- only backed up when it exists. PRD.md is
|
|
60
|
+
# intentionally included so that operators who ran `task prd:render` before
|
|
61
|
+
# migrating can still recover its pre-cutover contents.
|
|
62
|
+
_ROOT_MD_INPUTS: tuple[str, ...] = (
|
|
63
|
+
"SPECIFICATION.md",
|
|
64
|
+
"PROJECT.md",
|
|
65
|
+
"ROADMAP.md",
|
|
66
|
+
"PRD.md",
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
_VBRIEF_JSON_INPUTS: tuple[str, ...] = (
|
|
70
|
+
"specification.vbrief.json",
|
|
71
|
+
# #571 / #567 Greptile P1: the migrator force-bumps
|
|
72
|
+
# ``plan.vbrief.json`` to v0.6 when present, so the pre-bump bytes
|
|
73
|
+
# MUST be backed up to its ``.premigrate.*`` sibling for rollback
|
|
74
|
+
# to restore them. Without this entry the bump was not reversible
|
|
75
|
+
# and ``migrate -> rollback`` left a non-empty ``git status
|
|
76
|
+
# --porcelain`` on any project that carried a v0.5 plan.vbrief.json.
|
|
77
|
+
"plan.vbrief.json",
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
# ---------------------------------------------------------------------------
|
|
82
|
+
# Data classes
|
|
83
|
+
# ---------------------------------------------------------------------------
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@dataclass
|
|
87
|
+
class BackupRecord:
|
|
88
|
+
"""Single backup entry recorded in the safety manifest."""
|
|
89
|
+
|
|
90
|
+
source: str
|
|
91
|
+
"""Project-root-relative path of the pre-cutover input."""
|
|
92
|
+
|
|
93
|
+
backup: str
|
|
94
|
+
"""Project-root-relative path of the ``.premigrate.*`` copy."""
|
|
95
|
+
|
|
96
|
+
source_sha256: str
|
|
97
|
+
"""Hex digest of the pre-cutover content at backup time."""
|
|
98
|
+
|
|
99
|
+
size_bytes: int
|
|
100
|
+
"""Byte count of the pre-cutover content at backup time."""
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@dataclass
|
|
104
|
+
class FileModification:
|
|
105
|
+
"""In-place file-modification recorded in the safety manifest (#567).
|
|
106
|
+
|
|
107
|
+
Tracks every non-backup forward-pass edit the migrator performs on a
|
|
108
|
+
pre-existing project file (currently: ``.gitignore`` append) so the
|
|
109
|
+
rollback path can reverse it symmetrically to
|
|
110
|
+
:attr:`SafetyManifest.post_migration_stub_hashes` for redirect
|
|
111
|
+
stubs.
|
|
112
|
+
|
|
113
|
+
Attributes
|
|
114
|
+
----------
|
|
115
|
+
path
|
|
116
|
+
Project-root-relative path of the modified file.
|
|
117
|
+
operation
|
|
118
|
+
``"append"`` when the migrator added content to an existing
|
|
119
|
+
file, or ``"create"`` when the migrator created the file from
|
|
120
|
+
scratch (pre-migration state was "absent"). Additional
|
|
121
|
+
operations may be introduced as the migrator grows; rollback
|
|
122
|
+
refuses when it sees an operation it does not recognise.
|
|
123
|
+
pre_hash
|
|
124
|
+
sha256 of the file BEFORE the modification. Empty string when
|
|
125
|
+
the file did not exist pre-migration (operation == "create").
|
|
126
|
+
post_hash
|
|
127
|
+
sha256 of the file AFTER the modification. Used by rollback to
|
|
128
|
+
detect whether the operator has edited the file since migration;
|
|
129
|
+
rollback refuses (same pattern as
|
|
130
|
+
:attr:`SafetyManifest.post_migration_stub_hashes`) when the
|
|
131
|
+
current on-disk hash matches neither ``pre_hash`` nor
|
|
132
|
+
``post_hash``.
|
|
133
|
+
appended_content
|
|
134
|
+
Exact bytes the migrator appended (operation == "append") or
|
|
135
|
+
the full file content (operation == "create"). On rollback the
|
|
136
|
+
append case strips this suffix from the current file; the
|
|
137
|
+
create case deletes the file entirely.
|
|
138
|
+
"""
|
|
139
|
+
|
|
140
|
+
path: str
|
|
141
|
+
operation: str
|
|
142
|
+
pre_hash: str
|
|
143
|
+
post_hash: str
|
|
144
|
+
appended_content: str
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
@dataclass
|
|
148
|
+
class RenameRecord:
|
|
149
|
+
"""Single post-migration rename recorded in the safety manifest (#528).
|
|
150
|
+
|
|
151
|
+
When a ``deft-directive-*`` skill renames a file the migrator originally
|
|
152
|
+
created (e.g. Phase 6c of ``deft-directive-sync`` renames
|
|
153
|
+
``LEGACY-REPORT.md`` -> ``LEGACY-REPORT.reviewed.md``), the skill
|
|
154
|
+
appends one of these records to :attr:`SafetyManifest.renames` so
|
|
155
|
+
rollback can resolve the current on-disk name before attempting
|
|
156
|
+
removal. Without this, rollback would target the original name,
|
|
157
|
+
silently miss the renamed file, and leave the artefact + its parent
|
|
158
|
+
directory orphaned on disk (issue #528).
|
|
159
|
+
"""
|
|
160
|
+
|
|
161
|
+
original: str
|
|
162
|
+
"""Project-root-relative path of the file when the migrator created it."""
|
|
163
|
+
|
|
164
|
+
current: str
|
|
165
|
+
"""Project-root-relative path of the file on disk RIGHT NOW."""
|
|
166
|
+
|
|
167
|
+
renamed_by: str
|
|
168
|
+
"""Human-readable name of the skill/phase that performed the rename."""
|
|
169
|
+
|
|
170
|
+
renamed_at: str
|
|
171
|
+
"""UTC ISO-8601 timestamp (seconds precision) when the rename was recorded."""
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
@dataclass
|
|
175
|
+
class SafetyManifest:
|
|
176
|
+
"""State recorded at the end of a successful migration for rollback."""
|
|
177
|
+
|
|
178
|
+
version: str = "1"
|
|
179
|
+
migration_timestamp: str = ""
|
|
180
|
+
backups: list[BackupRecord] = field(default_factory=list)
|
|
181
|
+
created_files: list[str] = field(default_factory=list)
|
|
182
|
+
"""Project-root-relative paths of files the migrator wrote."""
|
|
183
|
+
|
|
184
|
+
created_dirs: list[str] = field(default_factory=list)
|
|
185
|
+
"""Project-root-relative paths of directories the migrator created.
|
|
186
|
+
|
|
187
|
+
Only includes directories that did NOT exist before the migration so
|
|
188
|
+
rollback can remove them without clobbering pre-existing structure.
|
|
189
|
+
"""
|
|
190
|
+
|
|
191
|
+
post_migration_stub_hashes: dict[str, str] = field(default_factory=dict)
|
|
192
|
+
"""``source -> sha256`` of redirect stubs at migration time.
|
|
193
|
+
|
|
194
|
+
On rollback, any diff between this recorded hash and the on-disk content
|
|
195
|
+
means the operator has edited the stub since migration -- we refuse to
|
|
196
|
+
restore (and lose their changes) unless ``--force`` is passed.
|
|
197
|
+
"""
|
|
198
|
+
|
|
199
|
+
renames: list[RenameRecord] = field(default_factory=list)
|
|
200
|
+
"""Post-migration renames recorded by downstream skills (#528).
|
|
201
|
+
|
|
202
|
+
Rollback consults this to resolve the current on-disk name of any
|
|
203
|
+
tracked file before attempting removal. The migrator never writes
|
|
204
|
+
entries here itself -- entries are appended by ``deft-directive-sync``
|
|
205
|
+
Phase 6c and any future skill that renames migrator-created files.
|
|
206
|
+
"""
|
|
207
|
+
|
|
208
|
+
file_modifications: list[FileModification] = field(default_factory=list)
|
|
209
|
+
"""In-place edits the migrator performed on pre-existing files (#567).
|
|
210
|
+
|
|
211
|
+
Currently limited to the ``.gitignore`` append, but the shape is
|
|
212
|
+
deliberately generic so future migrator features (e.g. README
|
|
213
|
+
patches, Taskfile ``includes:`` injection) can record here too.
|
|
214
|
+
Rollback iterates this list and either strips the appended content
|
|
215
|
+
(``operation == "append"``) or deletes the file entirely
|
|
216
|
+
(``operation == "create"``) if the current on-disk hash matches the
|
|
217
|
+
recorded ``post_hash``. When the hash matches neither ``pre_hash``
|
|
218
|
+
nor ``post_hash`` the operator has edited the file since migration
|
|
219
|
+
and rollback refuses -- same pattern as
|
|
220
|
+
:attr:`post_migration_stub_hashes` for SPECIFICATION.md /
|
|
221
|
+
PROJECT.md redirect stubs.
|
|
222
|
+
"""
|
|
223
|
+
|
|
224
|
+
def to_json(self) -> str:
|
|
225
|
+
payload = {
|
|
226
|
+
"version": self.version,
|
|
227
|
+
"migration_timestamp": self.migration_timestamp,
|
|
228
|
+
"backups": [asdict(b) for b in self.backups],
|
|
229
|
+
"created_files": list(self.created_files),
|
|
230
|
+
"created_dirs": list(self.created_dirs),
|
|
231
|
+
"post_migration_stub_hashes": dict(self.post_migration_stub_hashes),
|
|
232
|
+
"renames": [asdict(r) for r in self.renames],
|
|
233
|
+
"file_modifications": [asdict(m) for m in self.file_modifications],
|
|
234
|
+
}
|
|
235
|
+
return json.dumps(payload, indent=2, ensure_ascii=False) + "\n"
|
|
236
|
+
|
|
237
|
+
@classmethod
|
|
238
|
+
def from_json(cls, raw: str) -> SafetyManifest:
|
|
239
|
+
data = json.loads(raw)
|
|
240
|
+
backups = [BackupRecord(**b) for b in data.get("backups", [])]
|
|
241
|
+
renames = [RenameRecord(**r) for r in data.get("renames", [])]
|
|
242
|
+
# Backward compatible: older manifests have no file_modifications
|
|
243
|
+
# key at all (pre-#567); parse to an empty list so rollback still
|
|
244
|
+
# runs for tree-states produced by earlier migrator versions.
|
|
245
|
+
file_mods = [
|
|
246
|
+
FileModification(**m)
|
|
247
|
+
for m in data.get("file_modifications", [])
|
|
248
|
+
]
|
|
249
|
+
return cls(
|
|
250
|
+
version=str(data.get("version", "1")),
|
|
251
|
+
migration_timestamp=str(data.get("migration_timestamp", "")),
|
|
252
|
+
backups=backups,
|
|
253
|
+
created_files=list(data.get("created_files", [])),
|
|
254
|
+
created_dirs=list(data.get("created_dirs", [])),
|
|
255
|
+
post_migration_stub_hashes=dict(
|
|
256
|
+
data.get("post_migration_stub_hashes", {})
|
|
257
|
+
),
|
|
258
|
+
renames=renames,
|
|
259
|
+
file_modifications=file_mods,
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
def current_path_for(self, original: str) -> str:
|
|
263
|
+
"""Return the current on-disk path for a migrator-created ``original``.
|
|
264
|
+
|
|
265
|
+
Consults :attr:`renames` and follows genuine A -> B -> C chains:
|
|
266
|
+
each iteration looks up the *current* resolved path against the
|
|
267
|
+
``original`` field of every :class:`RenameRecord`. Within a single
|
|
268
|
+
hop, the most recent rename (last in list) wins when multiple
|
|
269
|
+
records target the same original. Terminates on a fixed-point or
|
|
270
|
+
when the bounded iteration count is exceeded (defensive guard
|
|
271
|
+
against pathological loops).
|
|
272
|
+
|
|
273
|
+
Also returns ``original`` when no record matches (#528; Greptile
|
|
274
|
+
#561 P2 clarified the chain contract).
|
|
275
|
+
"""
|
|
276
|
+
resolved = original
|
|
277
|
+
# A chain cannot be longer than the number of records in practice;
|
|
278
|
+
# bound the loop to ``len(renames) + 1`` so a hypothetical cycle
|
|
279
|
+
# aborts rather than spinning forever.
|
|
280
|
+
for _ in range(len(self.renames) + 1):
|
|
281
|
+
# Within one hop, scan every record that matches the current
|
|
282
|
+
# ``resolved`` name; the last matching record wins so two
|
|
283
|
+
# skills that both rename the same original land on the most
|
|
284
|
+
# recent destination (same-original semantics). Chain hops
|
|
285
|
+
# advance by looping again against the new ``resolved``.
|
|
286
|
+
target = resolved
|
|
287
|
+
for record in self.renames:
|
|
288
|
+
if record.original == resolved:
|
|
289
|
+
target = record.current
|
|
290
|
+
if target == resolved:
|
|
291
|
+
break
|
|
292
|
+
resolved = target
|
|
293
|
+
return resolved
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
# ---------------------------------------------------------------------------
|
|
297
|
+
# Backup planning / writing
|
|
298
|
+
# ---------------------------------------------------------------------------
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def premigrate_sibling(path: Path) -> Path:
|
|
302
|
+
"""Return the ``.premigrate.*`` sibling path for ``path``.
|
|
303
|
+
|
|
304
|
+
Preserves the full suffix chain -- `specification.vbrief.json` becomes
|
|
305
|
+
`specification.premigrate.vbrief.json`, and `SPECIFICATION.md` becomes
|
|
306
|
+
`SPECIFICATION.premigrate.md`. Files with no suffix get the sentinel
|
|
307
|
+
appended unchanged: `README` -> `README.premigrate`.
|
|
308
|
+
"""
|
|
309
|
+
name = path.name
|
|
310
|
+
if "." in name:
|
|
311
|
+
stem, rest = name.split(".", 1)
|
|
312
|
+
return path.with_name(f"{stem}{PREMIGRATE_SUFFIX}.{rest}")
|
|
313
|
+
return path.with_name(f"{name}{PREMIGRATE_SUFFIX}")
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
# Default deprecation redirect sentinel (mirrors migrate_vbrief.DEPRECATION_SENTINEL).
|
|
317
|
+
# Kept here to avoid an import cycle with migrate_vbrief. A caller may override via
|
|
318
|
+
# plan_backups(..., deprecation_sentinel=...) if the project-root sentinel ever changes.
|
|
319
|
+
_DEPRECATION_SENTINEL_DEFAULT = "<!-- deft:deprecated-redirect -->"
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def _is_deprecation_stub(path: Path, sentinel: str) -> bool:
|
|
323
|
+
"""Return True iff ``path`` already contains the deprecation redirect sentinel.
|
|
324
|
+
|
|
325
|
+
Protects re-run recovery (Greptile #509 P1): if the operator re-invokes
|
|
326
|
+
``task migrate:vbrief`` on an already-migrated project, the root-level
|
|
327
|
+
``SPECIFICATION.md`` / ``PROJECT.md`` are redirect stubs rather than
|
|
328
|
+
originals. Backing them up would overwrite the real ``.premigrate.*``
|
|
329
|
+
copies from the first run with stub bytes, destroying ``--rollback``
|
|
330
|
+
recovery. Files we cannot read (binary, permission-denied, missing
|
|
331
|
+
mid-call) are treated as non-stubs so we do not silently skip backups
|
|
332
|
+
that should have happened.
|
|
333
|
+
"""
|
|
334
|
+
try:
|
|
335
|
+
head = path.read_text(encoding="utf-8", errors="replace")[:4096]
|
|
336
|
+
except OSError:
|
|
337
|
+
return False
|
|
338
|
+
return sentinel in head
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def plan_backups(
|
|
342
|
+
project_root: Path,
|
|
343
|
+
*,
|
|
344
|
+
deprecation_sentinel: str = _DEPRECATION_SENTINEL_DEFAULT,
|
|
345
|
+
) -> list[tuple[Path, Path]]:
|
|
346
|
+
"""Return the list of ``(source, backup)`` pairs the migrator will write.
|
|
347
|
+
|
|
348
|
+
Only includes inputs that actually exist on disk so the caller can log and
|
|
349
|
+
emit one BACKUP line per real file.
|
|
350
|
+
|
|
351
|
+
Sources that already carry the deprecation redirect sentinel are skipped
|
|
352
|
+
(re-run protection -- see ``_is_deprecation_stub`` docstring).
|
|
353
|
+
"""
|
|
354
|
+
pairs: list[tuple[Path, Path]] = []
|
|
355
|
+
for name in _ROOT_MD_INPUTS:
|
|
356
|
+
src = project_root / name
|
|
357
|
+
if src.is_file() and not _is_deprecation_stub(src, deprecation_sentinel):
|
|
358
|
+
pairs.append((src, premigrate_sibling(src)))
|
|
359
|
+
vbrief_dir = project_root / "vbrief"
|
|
360
|
+
for name in _VBRIEF_JSON_INPUTS:
|
|
361
|
+
src = vbrief_dir / name
|
|
362
|
+
if src.is_file() and not _is_deprecation_stub(src, deprecation_sentinel):
|
|
363
|
+
pairs.append((src, premigrate_sibling(src)))
|
|
364
|
+
return pairs
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def write_backups(
|
|
368
|
+
project_root: Path,
|
|
369
|
+
pairs: Iterable[tuple[Path, Path]],
|
|
370
|
+
*,
|
|
371
|
+
dry_run: bool,
|
|
372
|
+
) -> tuple[list[BackupRecord], list[str]]:
|
|
373
|
+
"""Copy each ``(source, backup)`` pair and return manifest records + log.
|
|
374
|
+
|
|
375
|
+
Logs one ``BACKUP <src> -> <dst> (<N> bytes)`` action per pair regardless
|
|
376
|
+
of ``dry_run`` so the operator can see what would happen. In dry-run
|
|
377
|
+
mode no bytes are written.
|
|
378
|
+
"""
|
|
379
|
+
records: list[BackupRecord] = []
|
|
380
|
+
actions: list[str] = []
|
|
381
|
+
for src, dst in pairs:
|
|
382
|
+
raw = src.read_bytes()
|
|
383
|
+
digest = hashlib.sha256(raw).hexdigest()
|
|
384
|
+
size = len(raw)
|
|
385
|
+
rel_src = _rel(project_root, src)
|
|
386
|
+
rel_dst = _rel(project_root, dst)
|
|
387
|
+
if not dry_run:
|
|
388
|
+
dst.parent.mkdir(parents=True, exist_ok=True)
|
|
389
|
+
shutil.copy2(src, dst)
|
|
390
|
+
records.append(
|
|
391
|
+
BackupRecord(
|
|
392
|
+
source=rel_src,
|
|
393
|
+
backup=rel_dst,
|
|
394
|
+
source_sha256=digest,
|
|
395
|
+
size_bytes=size,
|
|
396
|
+
)
|
|
397
|
+
)
|
|
398
|
+
tag = "DRYRUN BACKUP" if dry_run else "BACKUP"
|
|
399
|
+
actions.append(f"{tag} {rel_src} -> {rel_dst} ({size} bytes)")
|
|
400
|
+
return records, actions
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
# ---------------------------------------------------------------------------
|
|
404
|
+
# Dirty-tree guard
|
|
405
|
+
# ---------------------------------------------------------------------------
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def is_tree_dirty(project_root: Path) -> bool:
|
|
409
|
+
"""Return True iff ``git status --porcelain`` reports uncommitted changes.
|
|
410
|
+
|
|
411
|
+
A project root that is NOT a git checkout -- or one where ``git`` is not
|
|
412
|
+
on PATH -- is treated as clean so tests using plain temp directories and
|
|
413
|
+
non-git consumers (e.g. tarball deployments) can migrate without a
|
|
414
|
+
special override. This matches #497 acceptance criteria phrasing of
|
|
415
|
+
"working tree is not clean".
|
|
416
|
+
"""
|
|
417
|
+
try:
|
|
418
|
+
result = subprocess.run(
|
|
419
|
+
["git", "status", "--porcelain"],
|
|
420
|
+
cwd=str(project_root),
|
|
421
|
+
capture_output=True,
|
|
422
|
+
text=True,
|
|
423
|
+
encoding="utf-8",
|
|
424
|
+
timeout=30,
|
|
425
|
+
check=False,
|
|
426
|
+
)
|
|
427
|
+
except (FileNotFoundError, subprocess.TimeoutExpired):
|
|
428
|
+
return False
|
|
429
|
+
if result.returncode != 0:
|
|
430
|
+
# Not a git repo, permission denied, etc. -- treat as clean rather
|
|
431
|
+
# than blocking migration, to avoid false-positive guard trips.
|
|
432
|
+
return False
|
|
433
|
+
return bool(result.stdout.strip())
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def dirty_tree_refusal_message() -> str:
|
|
437
|
+
"""Return the canonical refusal message used when the guard trips.
|
|
438
|
+
|
|
439
|
+
Centralised so the migrator CLI and tests agree on exact wording (Greptile
|
|
440
|
+
noise reduction).
|
|
441
|
+
"""
|
|
442
|
+
return (
|
|
443
|
+
"ERROR: Working tree is not clean. Migration is destructive; commit "
|
|
444
|
+
"or stash your changes first, then re-run.\n"
|
|
445
|
+
" Bypass with: task migrate:vbrief -- --force (not recommended)"
|
|
446
|
+
)
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
# ---------------------------------------------------------------------------
|
|
450
|
+
# Manifest IO
|
|
451
|
+
# ---------------------------------------------------------------------------
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def manifest_path(project_root: Path) -> Path:
|
|
455
|
+
return project_root / "vbrief" / MIGRATION_DIR / SAFETY_MANIFEST_NAME
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def write_safety_manifest(
|
|
459
|
+
project_root: Path,
|
|
460
|
+
manifest: SafetyManifest,
|
|
461
|
+
*,
|
|
462
|
+
dry_run: bool,
|
|
463
|
+
) -> str:
|
|
464
|
+
"""Write the safety manifest under ``vbrief/migration/`` and return a log line."""
|
|
465
|
+
target = manifest_path(project_root)
|
|
466
|
+
rel = _rel(project_root, target)
|
|
467
|
+
if dry_run:
|
|
468
|
+
return f"DRYRUN WRITE {rel} (safety manifest)"
|
|
469
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
470
|
+
target.write_text(manifest.to_json(), encoding="utf-8")
|
|
471
|
+
return f"WRITE {rel} (safety manifest, {len(manifest.backups)} backup(s))"
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
def load_safety_manifest(project_root: Path) -> SafetyManifest | None:
|
|
475
|
+
path = manifest_path(project_root)
|
|
476
|
+
if not path.is_file():
|
|
477
|
+
return None
|
|
478
|
+
try:
|
|
479
|
+
return SafetyManifest.from_json(path.read_text(encoding="utf-8"))
|
|
480
|
+
except (json.JSONDecodeError, TypeError, ValueError):
|
|
481
|
+
return None
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
def sha256_of(path: Path) -> str:
|
|
485
|
+
"""Return hex sha256 of ``path`` (empty string if path does not exist)."""
|
|
486
|
+
if not path.is_file():
|
|
487
|
+
return ""
|
|
488
|
+
return hashlib.sha256(path.read_bytes()).hexdigest()
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
# ---------------------------------------------------------------------------
|
|
492
|
+
# Rollback
|
|
493
|
+
# ---------------------------------------------------------------------------
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
def _default_confirm(prompt: str) -> bool:
|
|
497
|
+
"""Default interactive confirmation prompt used by ``rollback``."""
|
|
498
|
+
try:
|
|
499
|
+
reply = input(f"{prompt} [yes/NO]: ")
|
|
500
|
+
except EOFError:
|
|
501
|
+
return False
|
|
502
|
+
return reply.strip().lower() in {"yes", "y"}
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
def rollback(
|
|
506
|
+
project_root: Path,
|
|
507
|
+
*,
|
|
508
|
+
force: bool = False,
|
|
509
|
+
confirm_fn: Callable[[str], bool] | None = None,
|
|
510
|
+
) -> tuple[bool, list[str]]:
|
|
511
|
+
"""Restore a project to its pre-migration state.
|
|
512
|
+
|
|
513
|
+
Returns ``(ok, messages)`` where ``messages`` is a human-readable action
|
|
514
|
+
log. Fails fast if:
|
|
515
|
+
|
|
516
|
+
- No safety manifest exists (migration never ran, or rollback already
|
|
517
|
+
happened).
|
|
518
|
+
- A redirect stub has been edited since migration and ``force`` is False.
|
|
519
|
+
- The user declines the confirmation prompt (``--force`` skips this).
|
|
520
|
+
|
|
521
|
+
On success, restores every ``.premigrate.*`` backup, removes the scope
|
|
522
|
+
vBRIEFs / migration / legacy artefacts the migrator created, removes
|
|
523
|
+
directories the migrator created (only if they are empty after cleanup),
|
|
524
|
+
and deletes the manifest and the backup files themselves.
|
|
525
|
+
"""
|
|
526
|
+
actions: list[str] = []
|
|
527
|
+
manifest = load_safety_manifest(project_root)
|
|
528
|
+
if manifest is None:
|
|
529
|
+
return False, [
|
|
530
|
+
"ERROR: No safety manifest found. Either migration has not run, "
|
|
531
|
+
"or rollback has already completed. Expected "
|
|
532
|
+
f"{_rel(project_root, manifest_path(project_root))}."
|
|
533
|
+
]
|
|
534
|
+
|
|
535
|
+
# 1. Detect edited stubs unless force.
|
|
536
|
+
edited_stubs: list[tuple[str, str, str]] = []
|
|
537
|
+
for rel, expected_hash in manifest.post_migration_stub_hashes.items():
|
|
538
|
+
current = sha256_of(project_root / rel)
|
|
539
|
+
if current and current != expected_hash:
|
|
540
|
+
edited_stubs.append((rel, expected_hash, current))
|
|
541
|
+
if edited_stubs and not force:
|
|
542
|
+
lines = ["ERROR: Redirect stubs have been edited since migration:"]
|
|
543
|
+
for rel, expected, current in edited_stubs:
|
|
544
|
+
lines.append(
|
|
545
|
+
f" - {rel} (expected sha256 {expected[:12]}..., got {current[:12]}...)"
|
|
546
|
+
)
|
|
547
|
+
lines.append(
|
|
548
|
+
"Rollback would overwrite your edits. Re-run with --force to "
|
|
549
|
+
"proceed anyway, or commit the stubs before rolling back."
|
|
550
|
+
)
|
|
551
|
+
return False, lines
|
|
552
|
+
|
|
553
|
+
# 1b. Detect edited in-place file modifications unless force (#567).
|
|
554
|
+
# Mirrors the stub-hash guard for the ``.gitignore`` append and any
|
|
555
|
+
# future non-backup in-place edit the migrator records. A current
|
|
556
|
+
# hash that matches neither ``pre_hash`` (already reverted -- safe to
|
|
557
|
+
# skip) nor ``post_hash`` (untouched since migration -- safe to
|
|
558
|
+
# reverse) means the operator edited the file post-migration; we
|
|
559
|
+
# refuse to clobber those edits without ``--force``.
|
|
560
|
+
edited_modifications: list[tuple[str, str, str, str]] = []
|
|
561
|
+
for mod in manifest.file_modifications:
|
|
562
|
+
current = sha256_of(project_root / mod.path)
|
|
563
|
+
# operation == "create" + file absent post-rollback is fine; the
|
|
564
|
+
# guard only fires when the file exists but the hash doesn't
|
|
565
|
+
# match either snapshot.
|
|
566
|
+
if not current:
|
|
567
|
+
continue
|
|
568
|
+
if current in {mod.pre_hash, mod.post_hash}:
|
|
569
|
+
continue
|
|
570
|
+
edited_modifications.append(
|
|
571
|
+
(mod.path, mod.pre_hash, mod.post_hash, current)
|
|
572
|
+
)
|
|
573
|
+
if edited_modifications and not force:
|
|
574
|
+
lines = [
|
|
575
|
+
"ERROR: Migrator-modified file(s) have been edited since migration:"
|
|
576
|
+
]
|
|
577
|
+
for rel, pre, post, current in edited_modifications:
|
|
578
|
+
lines.append(
|
|
579
|
+
f" - {rel} (expected sha256 "
|
|
580
|
+
f"{post[:12]}... or {pre[:12]}..., got "
|
|
581
|
+
f"{current[:12]}...)"
|
|
582
|
+
)
|
|
583
|
+
lines.append(
|
|
584
|
+
"Rollback would overwrite your edits. Re-run with --force to "
|
|
585
|
+
"proceed anyway, or commit the file(s) before rolling back."
|
|
586
|
+
)
|
|
587
|
+
return False, lines
|
|
588
|
+
|
|
589
|
+
# 2. Confirmation prompt.
|
|
590
|
+
if not force:
|
|
591
|
+
prompt_fn = confirm_fn or _default_confirm
|
|
592
|
+
summary = (
|
|
593
|
+
f"Rollback will restore {len(manifest.backups)} backup(s) and "
|
|
594
|
+
f"remove {len(manifest.created_files)} migrator-created file(s). "
|
|
595
|
+
f"Proceed?"
|
|
596
|
+
)
|
|
597
|
+
if not prompt_fn(summary):
|
|
598
|
+
return False, ["Rollback aborted by operator."]
|
|
599
|
+
|
|
600
|
+
# 3. Pre-flight: make sure every recorded backup file is still on disk
|
|
601
|
+
# BEFORE we start restoring. If any is missing we refuse the rollback
|
|
602
|
+
# entirely rather than do a partial restore that would leave some sources
|
|
603
|
+
# as deprecation stubs while also deleting the manifest (which would make
|
|
604
|
+
# a retry impossible). Greptile P1 on #497: the prior implementation
|
|
605
|
+
# appended a warning and proceeded to (True, ...), printing a misleading
|
|
606
|
+
# "Rollback completed successfully" while half the tree was still stubs.
|
|
607
|
+
missing_backups = [
|
|
608
|
+
record.backup
|
|
609
|
+
for record in manifest.backups
|
|
610
|
+
if not (project_root / record.backup).is_file()
|
|
611
|
+
]
|
|
612
|
+
if missing_backups:
|
|
613
|
+
lines = [
|
|
614
|
+
"ERROR: Backup file(s) missing -- cannot restore all sources:",
|
|
615
|
+
*[f" - {p}" for p in missing_backups],
|
|
616
|
+
(
|
|
617
|
+
"Manifest preserved for investigation. Resolve the missing "
|
|
618
|
+
".premigrate.* file(s) (or restore from VCS) and retry "
|
|
619
|
+
"`task migrate:vbrief -- --rollback`."
|
|
620
|
+
),
|
|
621
|
+
]
|
|
622
|
+
return False, actions + lines
|
|
623
|
+
|
|
624
|
+
for record in manifest.backups:
|
|
625
|
+
backup_path = project_root / record.backup
|
|
626
|
+
source_path = project_root / record.source
|
|
627
|
+
source_path.parent.mkdir(parents=True, exist_ok=True)
|
|
628
|
+
shutil.copy2(backup_path, source_path)
|
|
629
|
+
actions.append(
|
|
630
|
+
f"RESTORE {record.source} <- {record.backup} ({record.size_bytes} bytes)"
|
|
631
|
+
)
|
|
632
|
+
|
|
633
|
+
# 4. Remove migrator-created files ONLY -- scoped strictly to
|
|
634
|
+
# manifest.created_files so rollback of this wave's run never deletes
|
|
635
|
+
# artefacts written by sibling waves that share `vbrief/migration/` or
|
|
636
|
+
# `vbrief/legacy/` (Agent D #498 writes validation-failure output there;
|
|
637
|
+
# Agent G #505 writes oversize legacy sections there). Greptile P2 on
|
|
638
|
+
# #497. Sort deepest-first so directory-removal in step 5 has a chance
|
|
639
|
+
# at emptying parents cleanly.
|
|
640
|
+
for rel in sorted(manifest.created_files, key=lambda p: -p.count("/")):
|
|
641
|
+
# #528: downstream skills (e.g. deft-directive-sync Phase 6c) may
|
|
642
|
+
# rename migrator-created files. Resolve the current on-disk name
|
|
643
|
+
# via manifest.renames before attempting removal so renamed files
|
|
644
|
+
# do not get orphaned with their parent directory.
|
|
645
|
+
current_rel = manifest.current_path_for(rel)
|
|
646
|
+
path = project_root / current_rel
|
|
647
|
+
if path.is_file():
|
|
648
|
+
path.unlink()
|
|
649
|
+
if current_rel != rel:
|
|
650
|
+
actions.append(f"REMOVE {current_rel} (renamed from {rel})")
|
|
651
|
+
else:
|
|
652
|
+
actions.append(f"REMOVE {rel}")
|
|
653
|
+
else:
|
|
654
|
+
if current_rel != rel:
|
|
655
|
+
actions.append(
|
|
656
|
+
f"SKIP {current_rel} (already absent; renamed from {rel})"
|
|
657
|
+
)
|
|
658
|
+
else:
|
|
659
|
+
actions.append(f"SKIP {rel} (already absent)")
|
|
660
|
+
|
|
661
|
+
# 5. Remove migrator-created directories (only if now-empty) -- also
|
|
662
|
+
# sorted deepest-first.
|
|
663
|
+
for rel in sorted(manifest.created_dirs, key=lambda p: -p.count("/")):
|
|
664
|
+
path = project_root / rel
|
|
665
|
+
if path.is_dir():
|
|
666
|
+
try:
|
|
667
|
+
path.rmdir()
|
|
668
|
+
actions.append(f"RMDIR {rel}")
|
|
669
|
+
except OSError:
|
|
670
|
+
actions.append(f"SKIP rmdir {rel} (not empty)")
|
|
671
|
+
|
|
672
|
+
# 5b. Reverse each recorded file_modification (#567). Runs BEFORE
|
|
673
|
+
# removing backup files so the log order matches the operator's
|
|
674
|
+
# mental model of "undo everything the forward pass did, then
|
|
675
|
+
# clean up the .premigrate.* siblings".
|
|
676
|
+
for mod in manifest.file_modifications:
|
|
677
|
+
target = project_root / mod.path
|
|
678
|
+
current = sha256_of(target)
|
|
679
|
+
if mod.operation == "create":
|
|
680
|
+
# Pre-migration state was "file absent". If the file is
|
|
681
|
+
# already gone, rollback is a no-op. Otherwise delete it --
|
|
682
|
+
# the force-path has already been gated on the hash guard
|
|
683
|
+
# above so we only reach here when the file is either at
|
|
684
|
+
# post_hash (created by us) or force is set.
|
|
685
|
+
if current and target.is_file():
|
|
686
|
+
target.unlink()
|
|
687
|
+
actions.append(f"REMOVE {mod.path} (created by migrator)")
|
|
688
|
+
else:
|
|
689
|
+
actions.append(
|
|
690
|
+
f"SKIP {mod.path} (already absent)"
|
|
691
|
+
)
|
|
692
|
+
continue
|
|
693
|
+
if mod.operation == "append":
|
|
694
|
+
if not current:
|
|
695
|
+
# File deleted since migration -- nothing to reverse.
|
|
696
|
+
actions.append(
|
|
697
|
+
f"SKIP {mod.path} (file no longer exists; "
|
|
698
|
+
f"nothing to strip)"
|
|
699
|
+
)
|
|
700
|
+
continue
|
|
701
|
+
if current == mod.pre_hash:
|
|
702
|
+
# Already reverted (operator manually reset the file).
|
|
703
|
+
actions.append(
|
|
704
|
+
f"SKIP {mod.path} (already at pre-migration hash)"
|
|
705
|
+
)
|
|
706
|
+
continue
|
|
707
|
+
try:
|
|
708
|
+
body = target.read_text(encoding="utf-8")
|
|
709
|
+
except OSError:
|
|
710
|
+
actions.append(
|
|
711
|
+
f"SKIP {mod.path} (unreadable; cannot strip append)"
|
|
712
|
+
)
|
|
713
|
+
continue
|
|
714
|
+
if body.endswith(mod.appended_content):
|
|
715
|
+
stripped = body[: -len(mod.appended_content)]
|
|
716
|
+
target.write_text(stripped, encoding="utf-8")
|
|
717
|
+
actions.append(
|
|
718
|
+
f"REVERT {mod.path} (stripped "
|
|
719
|
+
f"{len(mod.appended_content)} appended byte(s))"
|
|
720
|
+
)
|
|
721
|
+
else:
|
|
722
|
+
# Post-hash matched the snapshot but suffix no longer
|
|
723
|
+
# matches verbatim (rare: CRLF normalization after
|
|
724
|
+
# commit, etc.). Surface a clear message rather than
|
|
725
|
+
# silently leaving junk behind.
|
|
726
|
+
actions.append(
|
|
727
|
+
f"SKIP {mod.path} (content shape drifted; "
|
|
728
|
+
f"cannot strip append cleanly -- restore manually)"
|
|
729
|
+
)
|
|
730
|
+
continue
|
|
731
|
+
# Unknown operation -- be conservative and skip rather than
|
|
732
|
+
# mutating the file blindly.
|
|
733
|
+
actions.append(
|
|
734
|
+
f"SKIP {mod.path} (unknown operation {mod.operation!r})"
|
|
735
|
+
)
|
|
736
|
+
|
|
737
|
+
# 6. Remove the backup files themselves so the tree ends clean.
|
|
738
|
+
for record in manifest.backups:
|
|
739
|
+
backup_path = project_root / record.backup
|
|
740
|
+
if backup_path.is_file():
|
|
741
|
+
backup_path.unlink()
|
|
742
|
+
actions.append(f"REMOVE {record.backup}")
|
|
743
|
+
|
|
744
|
+
# 7. Finally, remove the manifest and its parent directory if empty.
|
|
745
|
+
m_path = manifest_path(project_root)
|
|
746
|
+
if m_path.is_file():
|
|
747
|
+
m_path.unlink()
|
|
748
|
+
actions.append(f"REMOVE {_rel(project_root, m_path)}")
|
|
749
|
+
for parent in (m_path.parent, m_path.parent.parent):
|
|
750
|
+
if parent.is_dir():
|
|
751
|
+
try:
|
|
752
|
+
parent.rmdir()
|
|
753
|
+
actions.append(f"RMDIR {_rel(project_root, parent)}")
|
|
754
|
+
except OSError:
|
|
755
|
+
# Non-empty -- leave it. Common when vbrief/ has other
|
|
756
|
+
# lifecycle folders the operator kept around.
|
|
757
|
+
pass
|
|
758
|
+
|
|
759
|
+
actions.append("Rollback completed successfully.")
|
|
760
|
+
return True, actions
|
|
761
|
+
|
|
762
|
+
|
|
763
|
+
# ---------------------------------------------------------------------------
|
|
764
|
+
# Helpers
|
|
765
|
+
# ---------------------------------------------------------------------------
|
|
766
|
+
|
|
767
|
+
|
|
768
|
+
def _rel(project_root: Path, target: Path) -> str:
|
|
769
|
+
"""Project-root-relative, forward-slash path for logs and manifest entries."""
|
|
770
|
+
try:
|
|
771
|
+
return target.relative_to(project_root).as_posix()
|
|
772
|
+
except ValueError:
|
|
773
|
+
return target.as_posix()
|
|
774
|
+
|
|
775
|
+
|
|
776
|
+
def now_utc_iso() -> str:
|
|
777
|
+
"""UTC timestamp in ISO-8601 seconds precision (matches vBRIEF `created`)."""
|
|
778
|
+
return datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
|