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