Skip to main content

release_kit/commands/
migrate.rs

1//! `rk migrate`: carry a target off another release tool through an
2//! archive and a greenfield landing.
3//!
4//! `inventory` lists what the archive moves. `archive` moves every
5//! predecessor file into a workspace under the state root, snapshots what
6//! no Git holds, writes the registry, and names the open migration in the
7//! committed configuration; `--rebuild` recreates the archived files from
8//! Git on a clone whose workspace is gone. `mark` records the map and the
9//! loop, `status` reads the registry back, and `close` ends the migration
10//! once nothing is unfinished and the setup proof is current. Every verb
11//! that writes the target previews by default.
12//!
13//! SATISFIES migration:an-archive-moves-only-committed-files
14//! SATISFIES migration:the-configuration-names-the-open-migration
15
16use 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
35/// Route one `rk migrate` invocation.
36///
37/// # Errors
38///
39/// Each verb's own refusals; every failure is typed.
40pub 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
85/// The target's canonical path, or the refusal for one that is not a
86/// directory.
87fn 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
100/// The workspace path of migration `id`, or the refusal where no state
101/// root resolves.
102fn workspace_of(canonical: &Path, id: &str) -> Result<Utf8PathBuf, RkError> {
103    Ok(runs_of(canonical)?.join(id))
104}
105
106/// The directory of every workspace at this target, or the refusal where
107/// no state root resolves.
108fn 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
123/// The open migration's id and workspace, and its registry.
124fn 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/// The inventory report.
136#[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/// One destructive step's forge snapshot, in a report.
177#[derive(Debug, Serialize)]
178struct ForgeRow {
179    step: String,
180    /// `taken` or `unknown`.
181    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/// The archive report.
189#[derive(Debug, Serialize)]
190struct ArchiveReport {
191    schema: &'static str,
192    /// `preview` for an archive or a rebuild that writes nothing, else
193    /// `apply`, `resume`, or `rebuild`.
194    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
206/// The kind of a forge snapshot in the registry.
207const FORGE_STATE: &str = "forge-state";
208
209/// A refusal that leaves the target unchanged.
210fn 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    // The trunk must hold every listed file's exact bytes, unfiltered, and
287    // no listed file the target lacks: a rebuild reads the files back from
288    // the trunk, so an ignored, uncommitted, or unintegrated file, one a
289    // filter converts, and an unintegrated deletion all refuse here.
290    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    // The apply claims its workspace exclusively, and a preview reads the
326    // name that claim would take, writing nothing.
327    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    // Copy first, and read each copy back against the checksum the
370    // inventory took, before any file leaves the target.
371    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, &registry)?;
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
458/// One registry source, pending since `at`.
459fn 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
485/// The Git snapshots no clone of the trunk holds: a bundle of every
486/// long-lived branch, and the tag list.
487fn 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
541/// Take the forge snapshots still missing, appending each one taken to
542/// `snapshots`; a target whose setup does not resolve reads unknown.
543fn 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
599/// Take the forge snapshots an open migration still misses.
600fn 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    // A forge snapshot whose file is gone or altered is no snapshot: it
608    // leaves the registry, an altered file is set aside rather than
609    // destroyed, and this run takes the snapshot again.
610    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, &registry)?;
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
682/// Remove the directories the archive emptied, up to and never including
683/// the target root.
684fn 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
696/// The commit the archive read its sources from: the first parent of the
697/// oldest commit on `HEAD`'s first-parent history that names the
698/// migration in the configuration. A merge is searched by its diff
699/// against its first parent, so a key a side branch carried in is found
700/// at the merge, whose first parent holds the trunk's bytes, never at the
701/// side commit, whose parent may predate the trunk's later edits.
702fn 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/// SATISFIES migration:a-rebuild-recreates-the-files-alone
736#[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    // Regular files alone, as the inventory lists them: a symlink or a
754    // submodule at the base is no file the archive took.
755    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        // With a registry, the archive's own list is the subject, never a
767        // file the base holds that the archive did not take.
768        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/// The mark report.
852#[derive(Debug, Serialize)]
853struct MarkReport {
854    schema: &'static str,
855    id: String,
856    status: Status,
857    marked: Marked,
858    next: Vec<String>,
859}
860
861/// SATISFIES migration:a-mark-appends-and-never-rewrites
862fn 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, &registry)?;
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/// One source in the status report.
889#[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    /// `same`, `changed`, or `missing`: the bytes now against the
902    /// checksum the archive took.
903    checksum: &'static str,
904}
905
906/// The status report.
907#[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    /// The destructive steps whose forge snapshot the registry lacks.
917    forge_missing: Vec<String>,
918    /// What still blocks the close.
919    blocking: Vec<String>,
920    next: Vec<String>,
921}
922
923/// Where the bytes of one source stand against its checksum.
924fn 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
936/// The destructive steps whose snapshot the registry lacks, and every
937/// reason the close refuses.
938fn 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 &registry.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, &registry)?;
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/// The close report.
1041#[derive(Debug, Serialize)]
1042struct CloseReport {
1043    schema: &'static str,
1044    /// `preview` or `apply`.
1045    mode: &'static str,
1046    id: String,
1047    workspace: String,
1048    closed: bool,
1049    next: Vec<String>,
1050}
1051
1052/// SATISFIES migration:close-refuses-an-unfinished-migration
1053fn close(target: &Utf8PathBuf, apply: bool, out: Output) -> Result<(), RkError> {
1054    let (id, workspace, registry) = open(target)?;
1055    let (_, reasons) = blocking(target, &workspace, &registry)?;
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}