67 """Structured output from :func:`run_migrate_option_c`."""
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"
76_ARTEFACT_SPEC: list[tuple[str, str, list[str], str]] = [
78 (
"octrc",
"octrc.json", [
".octrc.json"],
"file"),
82 [
"oc_diagnostics/debug_config.json",
"diagnostics/debug_config.json"],
85 (
"logs",
"logs", [
"logs"],
"dir"),
92 dry_run: bool =
False,
93 no_backup: bool =
False,
95 """Migrate a legacy Option C project into the ``.option_c/`` layout.
97 Returns a :class:`MigrationResult` describing what was copied,
98 skipped, or backed up. The caller renders the result.
100 func =
"run_migrate_option_c"
101 _dbg(
"MIGRATE", func, f
"start root={project_root} dry_run={dry_run}", 2)
107 result.messages.append(f
"[DRY RUN] Migration target: {oc_dir}")
108 for label, dest_rel, source_candidates, kind
in _ARTEFACT_SPEC:
111 result.messages.append(f
"[DRY RUN] {label}: not found — skip")
112 result.skipped.append((label,
"not found"))
114 dest = oc_dir / dest_rel
116 result.messages.append(
117 f
"[DRY RUN] {label}: {src} -> {dest} (destination exists, "
118 f
"{'overwrite' if no_backup else 'backup + overwrite'})"
121 result.messages.append(
122 f
"[DRY RUN] {label}: {src} -> {dest}"
124 result.messages.append(
125 f
"[DRY RUN] Would write {oc_dir / 'oc_status.json'}"
127 result.messages.append(
"[DRY RUN] Migration plan complete (no files written)")
128 _dbg(
"MIGRATE", func,
"dry-run end", 2)
131 oc_dir.mkdir(parents=
True, exist_ok=
True)
132 (oc_dir /
"cache").mkdir(exist_ok=
True)
134 migrated_from: dict[str, str] = {}
135 pillars_found: set[str] = set()
137 for label, dest_rel, source_candidates, kind
in _ARTEFACT_SPEC:
140 result.skipped.append((label,
"not found"))
141 _dbg(
"MIGRATE", func, f
"{label}: no source found, skipping", 3)
144 dest = oc_dir / dest_rel
148 result.skipped.append((label,
"already migrated"))
149 result.messages.append(
150 f
" {label}: already at {dest} (identical) — skip"
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)
159 backup = Path(str(dest) +
".bk")
162 shutil.rmtree(backup)
163 shutil.copytree(dest, backup)
165 shutil.copy2(dest, backup)
166 result.backed_up.append(backup)
167 result.messages.append(f
" {label}: backed up {dest} -> {backup}")
172 shutil.copytree(src, dest)
174 shutil.copy2(src, dest)
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)
182 _pillar_label_to_name = {
184 "debug_config":
"diagnostics",
187 pillar_names_found = {
188 _pillar_label_to_name.get(p, p)
for p
in pillars_found
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:
197 from importlib.metadata
import PackageNotFoundError, version
199 oct_version = version(
"oct")
200 except PackageNotFoundError:
201 oct_version =
"unknown"
203 oct_version =
"unknown"
206 today = datetime.date.today().isoformat()
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,
216 pillars=pillars_dict,
217 migrated_from=migrated_from,
218 oct_version_at_migration=oct_version,
220 write_oc_status(project_root, status)
221 result.messages.append(f
" oc_status.json: stage={stage}")
225 f
"end copied={len(result.copied)} skipped={len(result.skipped)} "
268 """Migrate to the canonical .option_c/ dotdir layout (FS-539)."""
269 project_root = get_project_root(ctx)
271 project_root, dry_run=dry_run, no_backup=no_backup,
273 for msg
in result.messages:
275 if not dry_run
and not result.copied
and not any(
276 reason ==
"already migrated" for _, reason
in result.skipped
278 click.echo(
"Nothing to migrate — no legacy artefacts found.")