Skip to main content

release_kit/
migrate.rs

1//! The archive-first migration from another release tool.
2//!
3//! A migration moves every predecessor file into a workspace under this
4//! tool's state root, lands a clean greenfield instance, and carries what
5//! the project still needs from the archive one group at a time. The
6//! workspace is the one place a run keeps state across runs: the archived
7//! files, the snapshots no Git holds, and `registry.yaml`, which only the
8//! `rk migrate` verbs write and whose per-item history is the migration's
9//! log. An open migration is visible to every clone through the
10//! `migration` key of the committed configuration.
11//!
12//! [`inventory`] is the one detector of predecessor files, [`registry`]
13//! owns the workspace's record, and [`git`] is the scrubbed git these
14//! verbs read the repository through.
15
16pub mod git;
17pub mod inventory;
18pub mod registry;
19
20use std::path::{Path, PathBuf};
21
22use camino::{Utf8Path, Utf8PathBuf};
23
24use crate::diagnostic::{Diagnostic, Reason};
25use crate::error::RkError;
26
27/// The machine shape every `rk migrate` report carries.
28pub const SCHEMA: &str = "rk.migrate/1";
29
30/// The directory below the state root that holds every workspace.
31pub const MIGRATIONS_DIR: &str = "migrations";
32
33/// The directory inside a workspace that holds the archived files, at
34/// their target-relative paths.
35pub const SOURCES_DIR: &str = "sources";
36
37/// The directory inside a workspace that holds the Git snapshots: the
38/// bundle of the long-lived branches and the tag list.
39pub const REFS_DIR: &str = "refs";
40
41/// The registry file inside a workspace.
42pub const REGISTRY_FILE: &str = "registry.yaml";
43
44/// The workspace of migration `id` at a target, below the state root:
45/// `<state root>/migrations/<repo key>/<id>/`.
46///
47/// The repo key is the one the snapshots use, so one target's saved
48/// copies and its migrations sit under one name.
49#[must_use]
50pub fn workspace(canonical_target: &Path, id: &str) -> Option<PathBuf> {
51    Some(runs(canonical_target)?.join(id))
52}
53
54/// The directory that holds every migration workspace of one target:
55/// `<state root>/migrations/<repo key>/`.
56#[must_use]
57pub fn runs(canonical_target: &Path) -> Option<PathBuf> {
58    Some(
59        crate::applog::state_root()?
60            .join(MIGRATIONS_DIR)
61            .join(crate::snapshot::repo_key(canonical_target)),
62    )
63}
64
65/// Claim a fresh workspace below `runs` for a migration opened at
66/// `stamp`, and return its identifier with its path.
67///
68/// The claim is the snapshots' own exclusive create, so a second archive
69/// in the same second takes `<stamp>-2`, and the identifier carries the
70/// counter.
71///
72/// # Errors
73///
74/// Any I/O error creating the workspace.
75pub fn claim(runs: &Path, stamp: &str) -> std::io::Result<(String, PathBuf)> {
76    let path = crate::snapshot::claim(runs, stamp)?;
77    let id = path
78        .file_name()
79        .and_then(std::ffi::OsStr::to_str)
80        .map(str::to_owned)
81        .ok_or_else(|| std::io::Error::other("a claimed workspace has no name"))?;
82    Ok((id, path))
83}
84
85/// The identifier a claim below `runs` would take now, read without
86/// creating anything: what a preview reports.
87#[must_use]
88pub fn free_id(runs: &Path, stamp: &str) -> String {
89    let mut id = stamp.to_owned();
90    let mut counter = 1_u32;
91    while runs.join(&id).exists() {
92        counter += 1;
93        id = format!("{stamp}-{counter}");
94    }
95    id
96}
97
98/// Whether `word` is a migration identifier.
99///
100/// An identifier is the UTC second the archive opened, `%Y%m%dT%H%M%SZ`,
101/// and `-<n>` from 2 up where an earlier archive took that second.
102/// Nothing else can name a workspace, so an identifier never reaches
103/// outside the target's migrations directory.
104#[must_use]
105pub fn is_id(word: &str) -> bool {
106    // Bytes, not characters: a hand-edited value may hold a multi-byte
107    // character anywhere, and it refuses rather than splitting it.
108    let bytes = word.as_bytes();
109    let (stamp, counter) = bytes.split_at(bytes.len().min(16));
110    let digits = |part: &[u8]| part.iter().all(u8::is_ascii_digit);
111    let stamp_ok = stamp.len() == 16
112        && digits(&stamp[..8])
113        && stamp[8] == b'T'
114        && digits(&stamp[9..15])
115        && stamp[15] == b'Z';
116    let counter_ok = counter.is_empty()
117        || counter.strip_prefix(b"-").is_some_and(|number| {
118            number.first().is_some_and(|first| *first != b'0')
119                && digits(number)
120                && std::str::from_utf8(number)
121                    .ok()
122                    .and_then(|text| text.parse::<u32>().ok())
123                    .is_some_and(|n| n >= 2)
124        });
125    stamp_ok && counter_ok
126}
127
128/// The migration the committed configuration names as open, if any.
129///
130/// # Errors
131///
132/// Propagates an unreadable or invalid configuration.
133pub fn open_id(target: &Utf8Path) -> Result<Option<String>, RkError> {
134    Ok(crate::config::load(target.as_std_path())?.and_then(|config| config.migration))
135}
136
137/// The workspace of the open migration, where one is open and a state
138/// root resolves.
139///
140/// # Errors
141///
142/// Propagates an unreadable configuration, and a target that does not
143/// canonicalize.
144pub fn open_workspace(target: &Utf8Path) -> Result<Option<Utf8PathBuf>, RkError> {
145    let Some(id) = open_id(target)? else {
146        return Ok(None);
147    };
148    let canonical = std::fs::canonicalize(target.as_std_path())?;
149    Ok(workspace(&canonical, &id).and_then(|path| Utf8PathBuf::from_path_buf(path).ok()))
150}
151
152/// A migration identifier: the UTC second it opened, `%Y%m%dT%H%M%SZ`.
153#[must_use]
154pub fn stamp(now: std::time::SystemTime) -> String {
155    at(now).replace(['-', ':'], "")
156}
157
158/// A history time: UTC to the second, `%Y-%m-%dT%H:%M:%SZ`.
159#[must_use]
160pub fn at(now: std::time::SystemTime) -> String {
161    humantime::format_rfc3339_seconds(now).to_string()
162}
163
164/// The refusal for a target with no open migration.
165#[must_use]
166pub fn none_open() -> RkError {
167    RkError::refusal(
168        Diagnostic::new(
169            Reason::PrerequisiteUnmet,
170            "no migration is open here; rk migrate archive opens one (migration:the-configuration-names-the-open-migration)",
171        )
172        .expected("a migration key in .release-kit/config.toml")
173        .action("rk migrate inventory --target . lists what an archive would move")
174        .target_state("unchanged"),
175    )
176}
177
178/// The refusal for an open migration whose workspace this host does not
179/// hold: the workspace is local state, and another clone rebuilds it.
180#[must_use]
181pub fn workspace_missing(id: &str, path: &Utf8Path) -> RkError {
182    RkError::refusal(
183        Diagnostic::new(
184            Reason::PrerequisiteUnmet,
185            format!(
186                "the migration {id} is open, and this host holds no workspace for it at {path} (migration:a-rebuild-recreates-the-files-alone)"
187            ),
188        )
189        .expected("the workspace the archive wrote, on this host")
190        .action("rk migrate archive --rebuild --apply --target . recreates the archived files from Git")
191        .target_state("unchanged"),
192    )
193}
194
195#[cfg(test)]
196mod tests {
197    use super::{at, claim, free_id, is_id, stamp};
198
199    #[test]
200    fn an_identifier_is_the_utc_second_it_opened() {
201        let when = std::time::UNIX_EPOCH + std::time::Duration::from_secs(1_791_554_525);
202        assert_eq!(at(when), "2026-10-09T14:02:05Z");
203        assert_eq!(stamp(when), "20261009T140205Z");
204    }
205
206    /// A second archive in the same second claims the next free name, and
207    /// both identifiers are ones the configuration accepts.
208    #[test]
209    fn a_second_archive_in_one_second_takes_the_next_free_identifier() {
210        let dir = tempfile::tempdir().expect("a scratch dir exists");
211        let runs = dir.path().join("migrations").join("widget-000000000000");
212        let stamp = "20261009T140205Z";
213        assert_eq!(free_id(&runs, stamp), stamp);
214        let (first, first_path) = claim(&runs, stamp).expect("claims");
215        assert_eq!(free_id(&runs, stamp), "20261009T140205Z-2");
216        let (second, second_path) = claim(&runs, stamp).expect("claims again");
217        assert_eq!(first, stamp);
218        assert_eq!(second, "20261009T140205Z-2");
219        assert_eq!(second_path, runs.join(&second));
220        assert_ne!(first_path, second_path);
221        assert!(is_id(&first) && is_id(&second));
222    }
223
224    #[test]
225    fn an_identifier_is_a_stamp_and_an_optional_counter() {
226        for good in [
227            "20261009T140205Z",
228            "20261009T140205Z-2",
229            "20261009T140205Z-12",
230        ] {
231            assert!(is_id(good), "{good}");
232        }
233        for bad in [
234            "",
235            "20261009T140205",
236            "20261009-140205Z",
237            "20261009T140205Z-",
238            "20261009T140205Z-1",
239            "20261009T140205Z-02",
240            "20261009T140205Z-2a",
241            "../20261009T140205Z",
242            "20261009T140205Z/..",
243            // A multi-byte character across byte 16, and one after the
244            // stamp, refuse rather than split.
245            "20261009T140205\u{e9}",
246            "20261009T140205\u{e9}-2",
247            "20261009T140205Z-\u{e9}",
248            "\u{e9}\u{e9}\u{e9}\u{e9}\u{e9}\u{e9}\u{e9}\u{e9}\u{e9}\u{e9}",
249        ] {
250            assert!(!is_id(bad), "{bad}");
251        }
252    }
253}