Option C Tools
Loading...
Searching...
No Matches
oct_migrate.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# oct/migrate/oct_migrate.py
4
5"""
6Purpose
7-------
8FS-539 — ``oct migrate option-c`` relocates a legacy Option C project
9into the canonical ``.option_c/`` dotdir layout introduced in v0.19.0.
10
11The command **copies** (never moves) each artefact so in-flight work on
12the legacy layout is not broken mid-release. Legacy files stay in place
13for one release; ``oct audit`` warns about the duplication and a future
14release will offer removal.
15
16Responsibilities
17----------------
18- Detect migratable artefacts: ``.octrc.json``, debug config (in either
19 ``oc_diagnostics/`` or ``diagnostics/``), ``logs/``.
20- In ``--dry-run`` mode, describe the plan without touching the disk.
21- Copy each artefact to ``.option_c/`` with ``.bk`` backup when a
22 destination already exists and differs.
23- Write ``oc_status.json`` recording the migration stage and source
24 paths.
25
26Diagnostics
27-----------
28Domain: MIGRATE
29Levels:
30 L2 — lifecycle: start / end
31 L3 — per-artefact decisions
32
33Contracts
34---------
35- Copies (never moves) — legacy files remain in place for one release.
36- ``--dry-run`` performs no filesystem writes.
37- A pre-existing destination that differs is backed up to ``.bk`` before overwrite.
38
39Dependencies
40------------
41- oct.core.diagnostics (_dbg structured logger)
42- oct.core.option_c_dir (OcStatus, write_oc_status, option_c_dir)
43- oct.core.project_root (project root detection)
44- click (CLI framework)
45"""
46
47from __future__ import annotations
48
49import json
50import shutil
51from dataclasses import dataclass, field
52from pathlib import Path
53
54import click
55
56from oct.core.diagnostics import _dbg
57from oct.core.option_c_dir import (
58 OcStatus,
59 option_c_dir,
60 write_oc_status,
61)
62from oct.core.project_root import get_project_root
63
64
65@dataclass
67 """Structured output from :func:`run_migrate_option_c`."""
68
69 copied: list[tuple[str, Path, Path]] = field(default_factory=list)
70 skipped: list[tuple[str, str]] = field(default_factory=list)
71 backed_up: list[Path] = field(default_factory=list)
72 messages: list[str] = field(default_factory=list)
73 stage: str = "bootstrap"
74
75
76_ARTEFACT_SPEC: list[tuple[str, str, list[str], str]] = [
77 # (label, dest_relative_to_.option_c, [source_candidates], kind)
78 ("octrc", "octrc.json", [".octrc.json"], "file"),
79 (
80 "debug_config",
81 "debug_config.json",
82 ["oc_diagnostics/debug_config.json", "diagnostics/debug_config.json"],
83 "file",
84 ),
85 ("logs", "logs", ["logs"], "dir"),
86]
87
88
90 project_root: Path,
91 *,
92 dry_run: bool = False,
93 no_backup: bool = False,
94) -> MigrationResult:
95 """Migrate a legacy Option C project into the ``.option_c/`` layout.
96
97 Returns a :class:`MigrationResult` describing what was copied,
98 skipped, or backed up. The caller renders the result.
99 """
100 func = "run_migrate_option_c"
101 _dbg("MIGRATE", func, f"start root={project_root} dry_run={dry_run}", 2)
102
103 result = MigrationResult()
104 oc_dir = option_c_dir(project_root)
105
106 if dry_run:
107 result.messages.append(f"[DRY RUN] Migration target: {oc_dir}")
108 for label, dest_rel, source_candidates, kind in _ARTEFACT_SPEC:
109 src = _find_source(project_root, source_candidates)
110 if src is None:
111 result.messages.append(f"[DRY RUN] {label}: not found — skip")
112 result.skipped.append((label, "not found"))
113 continue
114 dest = oc_dir / dest_rel
115 if dest.exists():
116 result.messages.append(
117 f"[DRY RUN] {label}: {src} -> {dest} (destination exists, "
118 f"{'overwrite' if no_backup else 'backup + overwrite'})"
119 )
120 else:
121 result.messages.append(
122 f"[DRY RUN] {label}: {src} -> {dest}"
123 )
124 result.messages.append(
125 f"[DRY RUN] Would write {oc_dir / 'oc_status.json'}"
126 )
127 result.messages.append("[DRY RUN] Migration plan complete (no files written)")
128 _dbg("MIGRATE", func, "dry-run end", 2)
129 return result
130
131 oc_dir.mkdir(parents=True, exist_ok=True)
132 (oc_dir / "cache").mkdir(exist_ok=True)
133
134 migrated_from: dict[str, str] = {}
135 pillars_found: set[str] = set()
136
137 for label, dest_rel, source_candidates, kind in _ARTEFACT_SPEC:
138 src = _find_source(project_root, source_candidates)
139 if src is None:
140 result.skipped.append((label, "not found"))
141 _dbg("MIGRATE", func, f"{label}: no source found, skipping", 3)
142 continue
143
144 dest = oc_dir / dest_rel
145
146 if dest.exists():
147 if _content_matches(src, dest, kind):
148 result.skipped.append((label, "already migrated"))
149 result.messages.append(
150 f" {label}: already at {dest} (identical) — skip"
151 )
152 _dbg("MIGRATE", func, f"{label}: dest identical, skip", 3)
153 pillars_found.add(label)
154 migrated_from[label] = str(
155 src.relative_to(project_root)
156 )
157 continue
158 if not no_backup:
159 backup = Path(str(dest) + ".bk")
160 if kind == "dir":
161 if backup.exists():
162 shutil.rmtree(backup)
163 shutil.copytree(dest, backup)
164 else:
165 shutil.copy2(dest, backup)
166 result.backed_up.append(backup)
167 result.messages.append(f" {label}: backed up {dest} -> {backup}")
168
169 if kind == "dir":
170 if dest.exists():
171 shutil.rmtree(dest)
172 shutil.copytree(src, dest)
173 else:
174 shutil.copy2(src, dest)
175
176 result.copied.append((label, src, dest))
177 result.messages.append(f" {label}: {src} -> {dest}")
178 pillars_found.add(label)
179 migrated_from[label] = src.relative_to(project_root).as_posix()
180 _dbg("MIGRATE", func, f"{label}: copied {src} -> {dest}", 3)
181
182 _pillar_label_to_name = {
183 "octrc": "linter",
184 "debug_config": "diagnostics",
185 "logs": "linter",
186 }
187 pillar_names_found = {
188 _pillar_label_to_name.get(p, p) for p in pillars_found
189 }
190 all_pillars = {"linter", "diagnostics", "git", "docs", "tests", "scaffold"}
191 stage = "compliant" if pillar_names_found else "bootstrap"
192 if pillar_names_found and pillar_names_found != all_pillars:
193 stage = "migrating"
194 result.stage = stage
195
196 try:
197 from importlib.metadata import PackageNotFoundError, version
198 try:
199 oct_version = version("oct")
200 except PackageNotFoundError:
201 oct_version = "unknown"
202 except ImportError:
203 oct_version = "unknown"
204
205 import datetime
206 today = datetime.date.today().isoformat()
207 pillars_dict = {}
208 for name in ("linter", "diagnostics", "git", "docs", "tests", "scaffold"):
209 pillars_dict[name] = {
210 "enabled": name in pillar_names_found,
211 "since": today if name in pillar_names_found else None,
212 }
213
214 status = OcStatus(
215 stage=stage,
216 pillars=pillars_dict,
217 migrated_from=migrated_from,
218 oct_version_at_migration=oct_version,
219 )
220 write_oc_status(project_root, status)
221 result.messages.append(f" oc_status.json: stage={stage}")
222
223 _dbg(
224 "MIGRATE", func,
225 f"end copied={len(result.copied)} skipped={len(result.skipped)} "
226 f"stage={stage}",
227 2,
228 )
229 return result
230
231
232def _find_source(project_root: Path, candidates: list[str]) -> Path | None:
233 for candidate in candidates:
234 path = project_root / candidate
235 if path.exists():
236 return path
237 return None
238
239
240def _content_matches(src: Path, dest: Path, kind: str) -> bool:
241 if kind == "file":
242 try:
243 return src.read_bytes() == dest.read_bytes()
244 except OSError:
245 return False
246 if kind == "dir":
247 return dest.is_dir()
248 return False
249
250
251# -- Click surface -------------------------------------------------------
252
253@click.group()
254@click.pass_context
255def migrate(ctx): # noqa: ARG001
256 """Migrate project layout to a newer Option C structure."""
257 pass
258
259
260@migrate.command(name="option-c")
261@click.option("--dry-run", is_flag=True, help="Show plan without writing anything.")
262@click.option(
263 "--no-backup", is_flag=True, default=False,
264 help="Skip .bk backup when overwriting an existing destination.",
265)
266@click.pass_context
267def migrate_option_c_cmd(ctx, dry_run: bool, no_backup: bool):
268 """Migrate to the canonical .option_c/ dotdir layout (FS-539)."""
269 project_root = get_project_root(ctx)
270 result = run_migrate_option_c(
271 project_root, dry_run=dry_run, no_backup=no_backup,
272 )
273 for msg in result.messages:
274 click.echo(msg)
275 if not dry_run and not result.copied and not any(
276 reason == "already migrated" for _, reason in result.skipped
277 ):
278 click.echo("Nothing to migrate — no legacy artefacts found.")
migrate_option_c_cmd(ctx, bool dry_run, bool no_backup)
Path|None _find_source(Path project_root, list[str] candidates)
bool _content_matches(Path src, Path dest, str kind)
MigrationResult run_migrate_option_c(Path project_root, *, bool dry_run=False, bool no_backup=False)