1use std::path::{Path, PathBuf};
17
18use camino::{Utf8Path, Utf8PathBuf};
19use serde::Serialize;
20
21use crate::cli::migrate::{MigrateAction, MigrateArgs};
22use crate::commands::setup::{self as setup_cmd, ForgeSnapshot};
23use crate::diagnostic::{Diagnostic, Reason};
24use crate::digest::Digest;
25use crate::error::RkError;
26use crate::landing::manifest;
27use crate::migrate::inventory::{self, Entry, Placement};
28use crate::migrate::registry::{
29 self, HistoryEntry, Integrity, Mark, Marked, Registry, Snapshot, Source, Status,
30};
31use crate::migrate::{self, git};
32use crate::output::Output;
33use crate::snapshot::Snapshot as Saved;
34
35pub fn run(args: &MigrateArgs) -> Result<(), RkError> {
41 match &args.action {
42 MigrateAction::Inventory { target, json } => inventory(target, Output::new(*json)),
43 MigrateAction::Archive {
44 target,
45 apply,
46 rebuild,
47 json,
48 } => {
49 if *rebuild {
50 rebuild_sources(target, *apply, Output::new(*json))
51 } else {
52 archive(target, *apply, Output::new(*json))
53 }
54 }
55 MigrateAction::Mark {
56 name,
57 status,
58 group,
59 tags,
60 destinations,
61 reason,
62 target,
63 json,
64 } => mark(
65 target,
66 &Mark {
67 name,
68 status: Some(Status::parse(status)?),
69 group: group.as_deref(),
70 tags,
71 destinations,
72 reason: reason.as_deref(),
73 },
74 Output::new(*json),
75 ),
76 MigrateAction::Status { target, json } => status(target, Output::new(*json)),
77 MigrateAction::Close {
78 target,
79 apply,
80 json,
81 } => close(target, *apply, Output::new(*json)),
82 }
83}
84
85fn canonical(target: &Utf8Path) -> Result<PathBuf, RkError> {
88 if !target.is_dir() {
89 return Err(RkError::missing(
90 Diagnostic::new(
91 Reason::TargetNotFound,
92 format!("{target} is not a directory"),
93 )
94 .expected("an existing repository"),
95 ));
96 }
97 Ok(std::fs::canonicalize(target.as_std_path())?)
98}
99
100fn workspace_of(canonical: &Path, id: &str) -> Result<Utf8PathBuf, RkError> {
103 Ok(runs_of(canonical)?.join(id))
104}
105
106fn runs_of(canonical: &Path) -> Result<Utf8PathBuf, RkError> {
109 migrate::runs(canonical)
110 .and_then(|path| Utf8PathBuf::from_path_buf(path).ok())
111 .ok_or_else(|| {
112 RkError::refusal(
113 Diagnostic::new(
114 Reason::PrerequisiteUnmet,
115 "no state root resolves, so the migration has no workspace, and nothing was written",
116 )
117 .expected("a state root under XDG_STATE_HOME or HOME")
118 .target_state("unchanged"),
119 )
120 })
121}
122
123fn open(target: &Utf8Path) -> Result<(String, Utf8PathBuf, Registry), RkError> {
125 let canonical = canonical(target)?;
126 let id = migrate::open_id(target)?.ok_or_else(migrate::none_open)?;
127 let workspace = workspace_of(&canonical, &id)?;
128 if !workspace.join(migrate::REGISTRY_FILE).is_file() {
129 return Err(migrate::workspace_missing(&id, &workspace));
130 }
131 let registry = registry::load(&workspace)?;
132 Ok((id, workspace, registry))
133}
134
135#[derive(Debug, Serialize)]
137struct InventoryReport {
138 schema: &'static str,
139 target: String,
140 files: Vec<Entry>,
141 next: Vec<String>,
142}
143
144fn inventory(target: &Utf8PathBuf, out: Output) -> Result<(), RkError> {
145 canonical(target)?;
146 let files = inventory::scan(target)?;
147 for entry in &files {
148 match entry.placement {
149 Placement::Archived => out.result_line(format!(
150 "archive {} ({}, {})",
151 entry.path,
152 entry.tool,
153 entry.kind.as_str()
154 )),
155 Placement::InPlace => out.result_line(format!("in place {}", entry.path)),
156 }
157 }
158 if !files
159 .iter()
160 .any(|entry| entry.placement == Placement::Archived)
161 {
162 out.result_line("no predecessor file is listed here");
163 }
164 let next = vec![format!(
165 "rk migrate archive --target {target} previews the move"
166 )];
167 out.next(&next);
168 out.emit(&InventoryReport {
169 schema: migrate::SCHEMA,
170 target: target.to_string(),
171 files,
172 next,
173 })
174}
175
176#[derive(Debug, Serialize)]
178struct ForgeRow {
179 step: String,
180 state: &'static str,
182 #[serde(skip_serializing_if = "Option::is_none")]
183 file: Option<String>,
184 #[serde(skip_serializing_if = "Option::is_none")]
185 detail: Option<String>,
186}
187
188#[derive(Debug, Serialize)]
190struct ArchiveReport {
191 schema: &'static str,
192 mode: &'static str,
195 target: String,
196 id: String,
197 workspace: String,
198 base_commit: String,
199 archived: Vec<String>,
200 in_place: Vec<String>,
201 snapshots: Vec<String>,
202 forge: Vec<ForgeRow>,
203 next: Vec<String>,
204}
205
206const FORGE_STATE: &str = "forge-state";
208
209fn refused(reason: Reason, message: String, expected: &str, action: Option<String>) -> RkError {
211 let mut diagnostic = Diagnostic::new(reason, message)
212 .expected(expected)
213 .target_state("unchanged");
214 if let Some(action) = action {
215 diagnostic = diagnostic.action(action);
216 }
217 RkError::refusal(diagnostic)
218}
219
220#[allow(
221 clippy::too_many_lines,
222 reason = "the archive is one ordered sequence of checks and writes, and splitting it would separate a write from the check that licensed it"
223)]
224fn archive(target: &Utf8PathBuf, apply: bool, out: Output) -> Result<(), RkError> {
225 let canonical = canonical(target)?;
226 if let Some(id) = migrate::open_id(target)? {
227 if apply {
228 return resume(target, &canonical, &id, out);
229 }
230 return Err(refused(
231 Reason::PrerequisiteUnmet,
232 format!(
233 "the migration {id} is already open; read it with rk migrate status (migration:the-configuration-names-the-open-migration)"
234 ),
235 "one open migration per target",
236 Some(format!(
237 "rk migrate archive --apply --target {target} takes the forge snapshots it still misses"
238 )),
239 ));
240 }
241 if manifest::load(target)?.is_some() {
242 return Err(refused(
243 Reason::PrerequisiteUnmet,
244 "this target carries a landing record, so it is upgraded or adopted rather than migrated (migration:an-archive-moves-only-committed-files)".to_owned(),
245 "a target another release tool drives, with no landing record",
246 Some(format!("rk status --target {target} names its classification")),
247 ));
248 }
249 let trunk = crate::config::trunk_of(target.as_std_path())?;
250 let trunk_ref = format!("refs/heads/{trunk}^{{commit}}");
251 let head = git::run(
252 target,
253 &["rev-parse", "--verify", "--quiet", trunk_ref.as_str()],
254 )?;
255 if !head.status.success() {
256 return Err(refused(
257 Reason::PrerequisiteUnmet,
258 format!(
259 "{target} has no local branch {trunk} with a commit, and the archive reads every listed file from the trunk (migration:an-archive-moves-only-committed-files)"
260 ),
261 "the trunk git.trunk names, as a local branch with at least one commit",
262 Some(format!(
263 "set git.trunk in {} to the trunk's branch name",
264 crate::config::CONFIG_PATH
265 )),
266 ));
267 }
268 let base_commit = String::from_utf8_lossy(&head.stdout).trim().to_owned();
269 let entries = inventory::scan(target)?;
270 let archived: Vec<&Entry> = entries
271 .iter()
272 .filter(|entry| entry.placement == Placement::Archived)
273 .collect();
274 if archived.is_empty() {
275 return Err(refused(
276 Reason::PrerequisiteUnmet,
277 format!(
278 "the inventory lists no predecessor file at {target}, so there is nothing to archive"
279 ),
280 "a predecessor release tool's files",
281 Some(format!(
282 "rk status --target {target} names its classification"
283 )),
284 ));
285 }
286 let known: Vec<&str> = inventory::PREDECESSORS
291 .iter()
292 .map(|(path, _, _)| *path)
293 .chain(inventory::ROOT_FILES)
294 .collect();
295 let blobs = git::regular_blobs(target, &base_commit, &known)?;
296 let paths: Vec<&str> = entries.iter().map(|entry| entry.path.as_str()).collect();
297 let raw = git::raw_blob_ids(target, &paths)?;
298 let mut differing: Vec<&str> = paths
299 .iter()
300 .zip(&raw)
301 .filter(|(path, oid)| blobs.get(**path) != Some(*oid))
302 .map(|(path, _)| *path)
303 .collect();
304 let on_trunk: Vec<&str> = blobs.keys().map(String::as_str).collect();
305 differing.extend(
306 inventory::listed(&on_trunk)
307 .into_iter()
308 .filter(|path| !paths.contains(path)),
309 );
310 if !differing.is_empty() {
311 return Err(refused(
312 Reason::StateDrift,
313 format!(
314 "these files differ from the trunk's copy on {trunk}: {}; integrate their edits onto the trunk, then run the archive again (migration:an-archive-moves-only-committed-files)",
315 differing.join(", ")
316 ),
317 "every listed file's bytes equal to its copy on the trunk, so Git holds the bytes the archive moves",
318 None,
319 ));
320 }
321 crate::config::check_writable(target.as_std_path())?;
322 let now = std::time::SystemTime::now();
323 let stamp = migrate::stamp(now);
324 let runs = runs_of(&canonical)?;
325 let (id, workspace) = if apply {
328 let (id, _) = migrate::claim(runs.as_std_path(), &stamp)?;
329 let workspace = runs.join(&id);
330 (id, workspace)
331 } else {
332 let id = migrate::free_id(runs.as_std_path(), &stamp);
333 let workspace = runs.join(&id);
334 (id, workspace)
335 };
336 let archived_paths: Vec<String> = archived.iter().map(|entry| entry.path.clone()).collect();
337 let in_place: Vec<String> = entries
338 .iter()
339 .filter(|entry| entry.placement == Placement::InPlace)
340 .map(|entry| entry.path.clone())
341 .collect();
342 if !apply {
343 for path in &archived_paths {
344 out.result_line(format!("would archive {path}"));
345 }
346 for path in &in_place {
347 out.result_line(format!("stays in place {path}"));
348 }
349 out.result_line(format!("would open the migration {id} at {workspace}"));
350 let next = vec![format!(
351 "rk migrate archive --apply --target {target} moves the files and opens the migration"
352 )];
353 out.next(&next);
354 return out.emit(&ArchiveReport {
355 schema: migrate::SCHEMA,
356 mode: "preview",
357 target: target.to_string(),
358 id,
359 workspace: workspace.to_string(),
360 base_commit,
361 archived: archived_paths,
362 in_place,
363 snapshots: Vec::new(),
364 forge: Vec::new(),
365 next,
366 });
367 }
368
369 let saved = Saved::at(workspace.clone().into_std_path_buf());
372 for entry in &archived {
373 let bytes = std::fs::read(target.join(&entry.path))?;
374 let path = saved.save(&Path::new(migrate::SOURCES_DIR).join(&entry.path), &bytes)?;
375 if Digest::of(&std::fs::read(&path)?).to_string() != entry.sha256 {
376 return Err(refused(
377 Reason::StateDrift,
378 format!(
379 "the archived copy of {} does not match the bytes the inventory read; the target is unchanged",
380 entry.path
381 ),
382 "the same bytes on both sides",
383 None,
384 ));
385 }
386 }
387 let at = migrate::at(now);
388 let mut snapshots = git_snapshots(target, &workspace, &saved, &at)?;
389 let forge = take_forge(target, &workspace, &saved, &at, &[], &mut snapshots);
390 let mut sources: Vec<Source> = entries
391 .iter()
392 .map(|entry| {
393 source_of(
394 &entry.path,
395 &entry.sha256,
396 entry.tool,
397 entry.kind,
398 entry.placement,
399 &at,
400 )
401 })
402 .collect();
403 registry::assign_ids(&mut sources);
404 let registry = Registry {
405 schema_version: registry::SCHEMA_VERSION,
406 id: id.clone(),
407 base_commit: base_commit.clone(),
408 sources,
409 groups: Vec::new(),
410 snapshots,
411 };
412 registry::save(&workspace, ®istry)?;
413 for path in &archived_paths {
414 std::fs::remove_file(target.join(path))?;
415 prune_empty_parents(target, path);
416 }
417 crate::config::set_migration(target.as_std_path(), Some(&id))?;
418 for path in &archived_paths {
419 out.result_line(format!("archived {path}"));
420 }
421 for row in &forge {
422 out.result_line(format!(
423 "forge snapshot {}: {}{}",
424 row.step,
425 row.state,
426 row.detail
427 .as_deref()
428 .map_or_else(String::new, |detail| format!(" ({detail})"))
429 ));
430 }
431 out.result_line(format!(
432 "opened the migration {id} at {workspace}; .release-kit/config.toml names it"
433 ));
434 let next = vec![
435 format!("rk init --target {target} previews the greenfield landing"),
436 format!("rk migrate status --target {target} reads the registry"),
437 ];
438 out.next(&next);
439 out.emit(&ArchiveReport {
440 schema: migrate::SCHEMA,
441 mode: "apply",
442 target: target.to_string(),
443 id,
444 workspace: workspace.to_string(),
445 base_commit,
446 archived: archived_paths,
447 in_place,
448 snapshots: registry
449 .snapshots
450 .iter()
451 .map(|snapshot| snapshot.id.clone())
452 .collect(),
453 forge,
454 next,
455 })
456}
457
458fn source_of(
460 path: &str,
461 sha256: &str,
462 tool: &str,
463 kind: inventory::Kind,
464 placement: Placement,
465 at: &str,
466) -> Source {
467 Source {
468 id: registry::source_id(path),
469 path: path.to_owned(),
470 sha256: sha256.to_owned(),
471 tool: tool.to_owned(),
472 kind,
473 placement,
474 tags: Vec::new(),
475 group: None,
476 status: Status::Pending,
477 history: vec![HistoryEntry {
478 at: at.to_owned(),
479 status: Status::Pending,
480 reason: None,
481 }],
482 }
483}
484
485fn git_snapshots(
488 target: &Utf8Path,
489 workspace: &Utf8Path,
490 saved: &Saved,
491 at: &str,
492) -> Result<Vec<Snapshot>, RkError> {
493 let mut snapshots = Vec::new();
494 let branches: Vec<String> = git::lines(
495 target,
496 &["for-each-ref", "--format=%(refname:short)", "refs/heads"],
497 )?
498 .into_iter()
499 .filter(|branch| {
500 crate::assess::LONG_LIVED_BRANCHES.contains(&branch.as_str())
501 || branch.starts_with("release/")
502 })
503 .collect();
504 if !branches.is_empty() {
505 let relative = format!("{}/branches.bundle", migrate::REFS_DIR);
506 let path = workspace.join(&relative);
507 if let Some(parent) = path.parent() {
508 std::fs::create_dir_all(parent)?;
509 }
510 let mut args = vec!["bundle", "create", path.as_str()];
511 args.extend(branches.iter().map(String::as_str));
512 git::stdout(target, &args)?;
513 snapshots.push(Snapshot {
514 id: "long-lived-branches".to_owned(),
515 kind: "git-bundle".to_owned(),
516 file: relative,
517 sha256: Digest::of(&std::fs::read(&path)?).to_string(),
518 taken_at: at.to_owned(),
519 second_copy: false,
520 });
521 }
522 let tags = git::lines(target, &["tag", "--list"])?;
523 let text = tags.iter().fold(String::new(), |mut text, tag| {
524 text.push_str(tag);
525 text.push('\n');
526 text
527 });
528 let relative = format!("{}/tags.txt", migrate::REFS_DIR);
529 saved.save(Path::new(&relative), text.as_bytes())?;
530 snapshots.push(Snapshot {
531 id: "tag-list".to_owned(),
532 kind: "tag-list".to_owned(),
533 file: relative,
534 sha256: Digest::of(text.as_bytes()).to_string(),
535 taken_at: at.to_owned(),
536 second_copy: false,
537 });
538 Ok(snapshots)
539}
540
541fn take_forge(
544 target: &Utf8PathBuf,
545 workspace: &Utf8Path,
546 saved: &Saved,
547 at: &str,
548 held: &[String],
549 snapshots: &mut Vec<Snapshot>,
550) -> Vec<ForgeRow> {
551 let taken = match setup_cmd::migration_snapshots(target, saved, held) {
552 Ok(taken) => taken,
553 Err(error) => {
554 return vec![ForgeRow {
555 step: "setup".to_owned(),
556 state: "unknown",
557 file: None,
558 detail: Some(error.to_string()),
559 }];
560 }
561 };
562 let mut rows = Vec::new();
563 for (step, result) in taken {
564 match result {
565 ForgeSnapshot::Taken(path) => {
566 let relative = Utf8PathBuf::from_path_buf(path.clone())
567 .ok()
568 .and_then(|path| path.strip_prefix(workspace).ok().map(Utf8Path::to_path_buf))
569 .map_or_else(|| path.display().to_string(), |path| path.to_string());
570 let sha256 = std::fs::read(&path)
571 .map(|bytes| Digest::of(&bytes).to_string())
572 .unwrap_or_default();
573 snapshots.push(Snapshot {
574 id: setup_cmd::forge_snapshot_id(step),
575 kind: FORGE_STATE.to_owned(),
576 file: relative.clone(),
577 sha256,
578 taken_at: at.to_owned(),
579 second_copy: false,
580 });
581 rows.push(ForgeRow {
582 step: step.to_owned(),
583 state: "taken",
584 file: Some(relative),
585 detail: None,
586 });
587 }
588 ForgeSnapshot::Unknown(detail) => rows.push(ForgeRow {
589 step: step.to_owned(),
590 state: "unknown",
591 file: None,
592 detail: Some(detail),
593 }),
594 }
595 }
596 rows
597}
598
599fn resume(target: &Utf8PathBuf, canonical: &Path, id: &str, out: Output) -> Result<(), RkError> {
601 let workspace = workspace_of(canonical, id)?;
602 if !workspace.join(migrate::REGISTRY_FILE).is_file() {
603 return Err(migrate::workspace_missing(id, &workspace));
604 }
605 let mut registry = registry::load(&workspace)?;
606 let now = std::time::SystemTime::now();
607 let stamp = migrate::stamp(now);
611 let mut aside = Vec::new();
612 let mut kept = Vec::new();
613 for snapshot in std::mem::take(&mut registry.snapshots) {
614 let state = if snapshot.kind == FORGE_STATE {
615 registry::integrity(&workspace, &snapshot)?
616 } else {
617 Integrity::Intact
618 };
619 match state {
620 Integrity::Intact => kept.push(snapshot),
621 Integrity::Missing => {}
622 Integrity::Altered => {
623 let moved = workspace.join(format!("{}.altered-{stamp}", snapshot.file));
624 std::fs::rename(workspace.join(&snapshot.file), &moved)?;
625 aside.push(moved);
626 }
627 }
628 }
629 registry.snapshots = kept;
630 let held: Vec<String> = registry
631 .snapshots
632 .iter()
633 .map(|snapshot| snapshot.id.clone())
634 .collect();
635 let saved = Saved::at(workspace.clone().into_std_path_buf());
636 let at = migrate::at(now);
637 let forge = take_forge(
638 target,
639 &workspace,
640 &saved,
641 &at,
642 &held,
643 &mut registry.snapshots,
644 );
645 registry::save(&workspace, ®istry)?;
646 for moved in &aside {
647 out.result_line(format!(
648 "set an altered snapshot aside at {moved}; its checksum no longer matched"
649 ));
650 }
651 for row in &forge {
652 out.result_line(format!("forge snapshot {}: {}", row.step, row.state));
653 }
654 if forge.is_empty() {
655 out.result_line(format!(
656 "the migration {id} holds every forge snapshot its destructive steps need"
657 ));
658 }
659 let next = vec![format!(
660 "rk migrate status --target {target} reads the registry"
661 )];
662 out.next(&next);
663 out.emit(&ArchiveReport {
664 schema: migrate::SCHEMA,
665 mode: "resume",
666 target: target.to_string(),
667 id: id.to_owned(),
668 workspace: workspace.to_string(),
669 base_commit: registry.base_commit.clone(),
670 archived: Vec::new(),
671 in_place: Vec::new(),
672 snapshots: registry
673 .snapshots
674 .iter()
675 .map(|snapshot| snapshot.id.clone())
676 .collect(),
677 forge,
678 next,
679 })
680}
681
682fn prune_empty_parents(target: &Utf8Path, path: &str) {
685 let mut current = Utf8Path::new(path).parent();
686 while let Some(dir) = current.filter(|dir| !dir.as_str().is_empty()) {
687 let full = target.join(dir);
688 let empty = std::fs::read_dir(&full).is_ok_and(|mut entries| entries.next().is_none());
689 if !empty || std::fs::remove_dir(&full).is_err() {
690 break;
691 }
692 current = dir.parent();
693 }
694}
695
696fn base_from_history(target: &Utf8Path, id: &str) -> Result<String, RkError> {
703 let needle = format!("-Smigration = \"{id}\"");
704 let commits = git::lines(
705 target,
706 &[
707 "log",
708 "--format=%H",
709 "--reverse",
710 "--first-parent",
711 "--diff-merges=first-parent",
712 needle.as_str(),
713 "--",
714 crate::config::CONFIG_PATH,
715 ],
716 )?;
717 let first = commits.first().ok_or_else(|| {
718 refused(
719 Reason::PrerequisiteUnmet,
720 format!(
721 "no commit names the migration {id} in {}, so the base commit cannot be found (migration:a-rebuild-recreates-the-files-alone)",
722 crate::config::CONFIG_PATH
723 ),
724 "the archive's configuration change committed",
725 None,
726 )
727 })?;
728 let parent = format!("{first}^1");
729 Ok(git::lines(target, &["rev-parse", parent.as_str()])?
730 .into_iter()
731 .next()
732 .unwrap_or_default())
733}
734
735#[allow(
737 clippy::too_many_lines,
738 reason = "one pass reads the base, checks each file against the registry, and writes or previews it"
739)]
740fn rebuild_sources(target: &Utf8PathBuf, apply: bool, out: Output) -> Result<(), RkError> {
741 let canonical = canonical(target)?;
742 let id = migrate::open_id(target)?.ok_or_else(migrate::none_open)?;
743 let workspace = workspace_of(&canonical, &id)?;
744 let existing = if workspace.join(migrate::REGISTRY_FILE).is_file() {
745 Some(registry::load(&workspace)?)
746 } else {
747 None
748 };
749 let base_commit = match &existing {
750 Some(registry) => registry.base_commit.clone(),
751 None => base_from_history(target, &id)?,
752 };
753 let blobs = git::regular_blobs(target, &base_commit, &[])?;
756 let paths: Vec<&str> = blobs.keys().map(String::as_str).collect();
757 let saved = Saved::at(workspace.clone().into_std_path_buf());
758 let at = migrate::at(std::time::SystemTime::now());
759 let mut sources = Vec::new();
760 let mut rebuilt = Vec::new();
761 let mut in_place = Vec::new();
762 for path in inventory::listed(&paths) {
763 let Some((tool, kind, placement)) = inventory::classify(path) else {
764 continue;
765 };
766 if existing
769 .as_ref()
770 .is_some_and(|registry| !registry.sources.iter().any(|source| source.path == path))
771 {
772 continue;
773 }
774 let object = format!("{base_commit}:{path}");
775 let bytes = git::stdout(target, &["show", object.as_str()])?;
776 let sha256 = Digest::of(&bytes).to_string();
777 if let Some(registry) = &existing
778 && let Some(source) = registry.sources.iter().find(|source| source.path == path)
779 && source.sha256 != sha256
780 {
781 return Err(refused(
782 Reason::StateDrift,
783 format!(
784 "{path} at {base_commit} does not match the registry's checksum, and nothing was rebuilt from it"
785 ),
786 "the bytes the archive took",
787 None,
788 ));
789 }
790 match placement {
791 Placement::Archived => {
792 if apply {
793 saved.save(&Path::new(migrate::SOURCES_DIR).join(path), &bytes)?;
794 }
795 rebuilt.push(path.to_owned());
796 }
797 Placement::InPlace => in_place.push(path.to_owned()),
798 }
799 sources.push(source_of(path, &sha256, tool, kind, placement, &at));
800 }
801 if apply && existing.is_none() {
802 registry::assign_ids(&mut sources);
803 registry::save(
804 &workspace,
805 &Registry {
806 schema_version: registry::SCHEMA_VERSION,
807 id: id.clone(),
808 base_commit: base_commit.clone(),
809 sources,
810 groups: Vec::new(),
811 snapshots: Vec::new(),
812 },
813 )?;
814 }
815 let verb = if apply { "rebuilt" } else { "would rebuild" };
816 for path in &rebuilt {
817 out.result_line(format!("{verb} {path}"));
818 }
819 out.result_line(format!(
820 "{verb} the files of the migration {id} at {workspace} from {base_commit}; a snapshot has no second copy, so none is rebuilt"
821 ));
822 let next = vec![if apply {
823 format!("rk migrate status --target {target} reads the registry")
824 } else {
825 format!("rk migrate archive --rebuild --apply --target {target} writes them")
826 }];
827 out.next(&next);
828 out.emit(&ArchiveReport {
829 schema: migrate::SCHEMA,
830 mode: if apply { "rebuild" } else { "preview" },
831 target: target.to_string(),
832 id,
833 workspace: workspace.to_string(),
834 base_commit,
835 archived: rebuilt,
836 in_place,
837 snapshots: existing
838 .map(|registry| {
839 registry
840 .snapshots
841 .into_iter()
842 .map(|snapshot| snapshot.id)
843 .collect()
844 })
845 .unwrap_or_default(),
846 forge: Vec::new(),
847 next,
848 })
849}
850
851#[derive(Debug, Serialize)]
853struct MarkReport {
854 schema: &'static str,
855 id: String,
856 status: Status,
857 marked: Marked,
858 next: Vec<String>,
859}
860
861fn mark(target: &Utf8PathBuf, request: &Mark<'_>, out: Output) -> Result<(), RkError> {
863 let (id, workspace, mut registry) = open(target)?;
864 let at = migrate::at(std::time::SystemTime::now());
865 let marked = registry.mark(request, &at)?;
866 registry::save(&workspace, ®istry)?;
867 let status = request.status.unwrap_or(Status::Pending);
868 out.result_line(format!(
869 "marked {} {} {}: {}",
870 marked.subject,
871 marked.id,
872 status.as_str(),
873 marked.sources.join(", ")
874 ));
875 let next = vec![format!(
876 "rk migrate status --target {target} reads the registry"
877 )];
878 out.next(&next);
879 out.emit(&MarkReport {
880 schema: migrate::SCHEMA,
881 id,
882 status,
883 marked,
884 next,
885 })
886}
887
888#[derive(Debug, Serialize)]
890struct SourceRow {
891 id: String,
892 path: String,
893 tool: String,
894 kind: inventory::Kind,
895 placement: Placement,
896 status: Status,
897 #[serde(skip_serializing_if = "Option::is_none")]
898 group: Option<String>,
899 #[serde(skip_serializing_if = "Vec::is_empty")]
900 tags: Vec<String>,
901 checksum: &'static str,
904}
905
906#[derive(Debug, Serialize)]
908struct StatusReport {
909 schema: &'static str,
910 id: String,
911 workspace: String,
912 base_commit: String,
913 sources: Vec<SourceRow>,
914 groups: Vec<registry::Group>,
915 snapshots: Vec<Snapshot>,
916 forge_missing: Vec<String>,
918 blocking: Vec<String>,
920 next: Vec<String>,
921}
922
923fn checksum(target: &Utf8Path, workspace: &Utf8Path, source: &Source) -> &'static str {
925 let path = match source.placement {
926 Placement::Archived => workspace.join(migrate::SOURCES_DIR).join(&source.path),
927 Placement::InPlace => target.join(&source.path),
928 };
929 match std::fs::read(&path) {
930 Ok(bytes) if Digest::of(&bytes).to_string() == source.sha256 => "same",
931 Ok(_) => "changed",
932 Err(_) => "missing",
933 }
934}
935
936fn blocking(
939 target: &Utf8PathBuf,
940 workspace: &Utf8Path,
941 registry: &Registry,
942) -> Result<(Vec<String>, Vec<String>), RkError> {
943 let mut held: Vec<&str> = Vec::new();
944 for snapshot in ®istry.snapshots {
945 if registry::integrity(workspace, snapshot)? == Integrity::Intact {
946 held.push(snapshot.id.as_str());
947 }
948 }
949 let forge_missing: Vec<String> = setup_cmd::destructive_steps(target)
950 .unwrap_or_default()
951 .into_iter()
952 .filter(|step| !held.contains(&setup_cmd::forge_snapshot_id(step).as_str()))
953 .map(str::to_owned)
954 .collect();
955 let mut reasons = registry.unfinished(target);
956 match setup_cmd::proof_word(target) {
957 Ok("compatible") => {}
958 Ok(word) => reasons.push(format!(
959 "the setup proof is {word}; rk setup checkpoint on a host that holds the bot's key records the setup as satisfied"
960 )),
961 Err(error) => reasons.push(format!("the setup contract does not resolve: {error}")),
962 }
963 Ok((forge_missing, reasons))
964}
965
966fn status(target: &Utf8PathBuf, out: Output) -> Result<(), RkError> {
967 let (id, workspace, registry) = open(target)?;
968 let sources: Vec<SourceRow> = registry
969 .sources
970 .iter()
971 .map(|source| SourceRow {
972 id: source.id.clone(),
973 path: source.path.clone(),
974 tool: source.tool.clone(),
975 kind: source.kind,
976 placement: source.placement,
977 status: source.status,
978 group: source.group.clone(),
979 tags: source.tags.clone(),
980 checksum: checksum(target, &workspace, source),
981 })
982 .collect();
983 let (forge_missing, reasons) = blocking(target, &workspace, ®istry)?;
984 out.result_line(format!(
985 "migration {id} at {workspace}, base {}",
986 registry.base_commit
987 ));
988 for row in &sources {
989 out.result_line(format!(
990 "{} {} ({}{}){}",
991 row.status.as_str(),
992 row.path,
993 row.group
994 .as_deref()
995 .map_or_else(|| "no group".to_owned(), |group| format!("group {group}")),
996 if row.tags.is_empty() {
997 String::new()
998 } else {
999 format!("; tags {}", row.tags.join(", "))
1000 },
1001 if row.checksum == "same" {
1002 String::new()
1003 } else {
1004 format!(" — checksum {}", row.checksum)
1005 }
1006 ));
1007 }
1008 for step in &forge_missing {
1009 out.result_line(format!(
1010 "no forge snapshot of {step}: the step stays refused"
1011 ));
1012 }
1013 for reason in &reasons {
1014 out.result_line(format!("blocks the close: {reason}"));
1015 }
1016 let next = if reasons.is_empty() {
1017 vec![format!(
1018 "rk migrate close --target {target} previews the close"
1019 )]
1020 } else {
1021 vec![format!(
1022 "rk migrate mark <source-or-group> <status> --target {target} records the next step"
1023 )]
1024 };
1025 out.next(&next);
1026 out.emit(&StatusReport {
1027 schema: migrate::SCHEMA,
1028 id,
1029 workspace: workspace.to_string(),
1030 base_commit: registry.base_commit.clone(),
1031 sources,
1032 groups: registry.groups.clone(),
1033 snapshots: registry.snapshots,
1034 forge_missing,
1035 blocking: reasons,
1036 next,
1037 })
1038}
1039
1040#[derive(Debug, Serialize)]
1042struct CloseReport {
1043 schema: &'static str,
1044 mode: &'static str,
1046 id: String,
1047 workspace: String,
1048 closed: bool,
1049 next: Vec<String>,
1050}
1051
1052fn close(target: &Utf8PathBuf, apply: bool, out: Output) -> Result<(), RkError> {
1054 let (id, workspace, registry) = open(target)?;
1055 let (_, reasons) = blocking(target, &workspace, ®istry)?;
1056 if !reasons.is_empty() {
1057 return Err(refused(
1058 Reason::PrerequisiteUnmet,
1059 format!(
1060 "the migration {id} cannot close: {} (migration:close-refuses-an-unfinished-migration)",
1061 reasons.join("; ")
1062 ),
1063 "every source verified or dropped, each verified source's group naming destinations the target holds, and a compatible setup proof",
1064 Some(format!(
1065 "rk migrate status --target {target} lists what is left"
1066 )),
1067 ));
1068 }
1069 if apply {
1070 crate::config::set_migration(target.as_std_path(), None)?;
1071 out.result_line(format!(
1072 "closed the migration {id}; .release-kit/config.toml no longer names it"
1073 ));
1074 } else {
1075 out.result_line(format!("the migration {id} can close"));
1076 }
1077 let next = if apply {
1078 vec![
1079 format!("keep or remove the workspace at {workspace}; its removal is the operator's"),
1080 "the first release through the rk-release skill is the proof".to_owned(),
1081 ]
1082 } else {
1083 vec![format!(
1084 "rk migrate close --apply --target {target} removes the key"
1085 )]
1086 };
1087 out.next(&next);
1088 out.emit(&CloseReport {
1089 schema: migrate::SCHEMA,
1090 mode: if apply { "apply" } else { "preview" },
1091 id,
1092 workspace: workspace.to_string(),
1093 closed: apply,
1094 next,
1095 })
1096}