Skip to main content

release_kit/stage/
compare.rs

1//! `rk stage compare`: what a landing from a stage would do, destination
2//! by destination, with the evidence that decides who owns each one.
3//!
4//! The comparison reads the stage's receipt, resolves its answers the way
5//! `--stage` does, and runs the production decision over the target as it
6//! stands, so the word a row carries is the word the landing prints. Each
7//! row states how the target's bytes stand against the candidate, the
8//! destination's kind, and, where the bytes differ, the provenance: the
9//! record half is whether the receipt vouches for the bytes on disk, and
10//! the history half is whether Git ever touched the path. A destination
11//! no receipt names also says whether its history shows an edit after
12//! the commit that brought it. The same verb is the proof after a
13//! landing: every rendered destination then reads `equal`.
14//!
15//! SATISFIES staging:the-comparison-is-the-landing-decision
16
17use std::path::Path;
18use std::process::Command;
19
20use camino::Utf8Path;
21use serde::Serialize;
22
23use super::{ARTIFACTS_DIR, read};
24use crate::digest::Digest;
25use crate::error::RkError;
26use crate::landing;
27use crate::landing::apply::{self, Held};
28use crate::landing::manifest;
29
30/// The report's shape version.
31pub const SCHEMA: &str = "rk.stage-compare/3";
32
33/// One destination, compared.
34#[derive(Debug, Clone, Serialize)]
35pub struct Row {
36    /// The destination, relative to the target.
37    pub path: String,
38    /// `equal`, `absent`, or `differs` for a candidate. For a destination
39    /// the stage no longer carries: `retired` while the landing still has
40    /// to remove it, `equal` once it is gone from the disk and the record,
41    /// and `dropped` where the target keeps it.
42    pub state: &'static str,
43    /// `rendered`, `region`, `seeded`, or `state`.
44    pub kind: &'static str,
45    /// For a destination that differs, is retired, or is dropped: `attributed` where
46    /// the record vouches for the bytes and Git touched the path,
47    /// `reduced` where one of the two holds, `unknown` where neither does.
48    pub provenance: Option<&'static str>,
49    /// Whether the receipt vouches for the bytes the target holds; null
50    /// where the receipt names no such destination or nothing stands.
51    pub vouched: Option<bool>,
52    /// For a destination no receipt names: whether its history shows an
53    /// edit after the commit that brought it; null where Git cannot say.
54    pub edited_after_added: Option<bool>,
55    /// What the production landing prints for it: `created`, `replaced`,
56    /// `matched`, `preserved`, `drift`, `retired`, `dropped`, or
57    /// `collision`.
58    pub word: &'static str,
59    /// Whether the landing verb accepts `--replace` for it: true only for
60    /// a `collision` the production decision itself marks replaceable, so
61    /// any other collision is a refusal no flag passes.
62    pub replaceable: bool,
63}
64
65/// Whether the history half could be read.
66#[derive(Debug, Clone, Serialize)]
67pub struct History {
68    /// Whether Git answers for the target.
69    pub available: bool,
70    /// Why not, where it does not.
71    pub reason: Option<String>,
72}
73
74/// The machine form of `rk stage compare`.
75#[derive(Debug, Clone, Serialize)]
76pub struct Report {
77    /// The shape version of this document.
78    pub schema: &'static str,
79    /// The stage compared from.
80    pub stage_root: String,
81    /// The target compared.
82    pub target: String,
83    /// Whether the history half could be read.
84    pub history: History,
85    /// One row per destination the stage carries or the record names.
86    pub rows: Vec<Row>,
87}
88
89/// Compare the stage at `root` with `target`.
90///
91/// # Errors
92///
93/// The stage reader's refusals for another binary's or another target's
94/// stage, the parameter resolution's refusals, and read failures.
95#[allow(
96    clippy::too_many_lines,
97    reason = "one pass resolves the stage's answers, runs the production decision, and judges every row"
98)]
99pub fn compare(root: &Utf8Path, target: &Utf8Path) -> Result<Report, RkError> {
100    let canonical = std::fs::canonicalize(target.as_std_path())?;
101    let receipt = read::load(root, &canonical)?;
102    let held = Held::open(target)?;
103    let recorded = manifest::load(held.base())?;
104    let config = crate::config::load(held.base().as_std_path())?;
105    // Git cannot enter the held base, so the standing reads the path, as
106    // the history below does.
107    let purpose = super::purpose(target, config.as_ref(), recorded.is_some())?;
108    let params = landing::Params::resolve(
109        held.base(),
110        &read::inputs(&receipt.parameters),
111        config.as_ref(),
112        recorded.as_ref(),
113        purpose,
114    )?;
115    read::confirm(&params, &receipt, root)?;
116    let prepared = apply::prepare(&held, recorded.as_ref(), &params, config.as_ref())?;
117    let history = match crate::assess::git_work_tree(target) {
118        Ok(true) => History {
119            available: true,
120            reason: None,
121        },
122        Ok(false) => History {
123            available: false,
124            reason: Some(
125                "the target is not a non-bare Git work tree, so the history half reads as missing"
126                    .to_owned(),
127            ),
128        },
129        Err(error) => History {
130            available: false,
131            reason: Some(format!(
132                "git did not answer, so the history half reads as missing: {error}"
133            )),
134        },
135    };
136    // The word is the decision of the verb that lands this stage: an
137    // adoption judges each destination itself and writes nothing else.
138    let adopting = purpose == landing::Purpose::Adopt;
139    // The upgrade refuses a record from a newer binary before it reaches
140    // any replace check, so nothing it would refuse reads replaceable.
141    let refused = recorded.as_ref().is_some_and(|record| {
142        manifest::alignment(&record.rk_version, env!("CARGO_PKG_VERSION"))
143            == manifest::Alignment::TargetNewer
144    });
145    let word = |destination: &str| -> Result<(&'static str, bool), RkError> {
146        if adopting
147            && let Some(candidate) = prepared
148                .projection
149                .candidates
150                .iter()
151                .find(|candidate| candidate.destination == destination)
152        {
153            let judged = apply::judge_adoption(&held, candidate)?;
154            return Ok((judged.word(), judged.replaceable(candidate.kind)));
155        }
156        let replaceable = !refused
157            && prepared
158                .collisions
159                .iter()
160                .any(|collision| collision.path == destination && collision.replaceable);
161        Ok((
162            prepared
163                .decision(destination)
164                .map_or("collision", |decision| decision.action.as_str()),
165            replaceable,
166        ))
167    };
168    let mut rows = Vec::new();
169    for candidate in &receipt.candidates {
170        let region = candidate.placement == "region";
171        let artifact = std::fs::read(
172            root.join(ARTIFACTS_DIR)
173                .join(&candidate.destination)
174                .as_std_path(),
175        )?;
176        let expected = if region {
177            region_of(&candidate.destination, &artifact).unwrap_or(artifact)
178        } else {
179            artifact
180        };
181        let current = landing::read_recorded(held.base(), &candidate.destination)?;
182        let state = match &current {
183            None => "absent",
184            Some(bytes) if *bytes == expected => "equal",
185            Some(_) => "differs",
186        };
187        let record = recorded
188            .as_ref()
189            .and_then(|record| record.file(&candidate.destination));
190        rows.push(row(
191            &candidate.destination,
192            state,
193            if region {
194                "region"
195            } else {
196                candidate.kind.as_str()
197            },
198            record,
199            current.as_deref(),
200            &history,
201            target,
202            word(&candidate.destination)?,
203        ));
204    }
205    // Each destination the stage no longer carries, judged from the target
206    // as it stands now: the stage's lists only say which paths to read.
207    // The production decision answers while the record still names one. A
208    // path the record no longer names is settled: retired where nothing
209    // stands, and dropped to the target where its file stays.
210    for destination in receipt.retired.iter().chain(&receipt.dropped) {
211        let record = recorded
212            .as_ref()
213            .and_then(|record| record.file(destination));
214        let kind = record.map_or("rendered", |record| match record.placement {
215            manifest::Placement::Region => "region",
216            manifest::Placement::Whole => record.kind.as_str(),
217        });
218        // An entry no read answers, such as a directory where the file
219        // stood, is dropped and still has its row: nothing vouches for it,
220        // its provenance is unknown, and every other destination is
221        // reported.
222        let Ok(current) = landing::read_recorded(held.base(), destination) else {
223            rows.push(Row {
224                path: destination.clone(),
225                state: "dropped",
226                kind,
227                provenance: Some("unknown"),
228                vouched: None,
229                edited_after_added: None,
230                word: prepared
231                    .decision(destination)
232                    .map_or("dropped", |decision| decision.action.as_str()),
233                replaceable: false,
234            });
235            continue;
236        };
237        let (state, word) = match prepared.decision(destination) {
238            Some(decision) if decision.action == apply::Action::Retired => ("retired", "retired"),
239            Some(decision) => ("dropped", decision.action.as_str()),
240            None if current.is_some() => ("dropped", "dropped"),
241            None if receipt.dropped.contains(destination) => ("equal", "dropped"),
242            None => ("equal", "retired"),
243        };
244        rows.push(row(
245            destination,
246            state,
247            kind,
248            record,
249            current.as_deref(),
250            &history,
251            target,
252            (word, false),
253        ));
254    }
255    Ok(Report {
256        schema: SCHEMA,
257        stage_root: receipt.stage_root,
258        target: receipt.target,
259        history,
260        rows,
261    })
262}
263
264/// One row, its provenance computed from the record and the history.
265#[allow(
266    clippy::too_many_arguments,
267    reason = "a row joins the stage, the record, the disk, and the history, each one argument"
268)]
269fn row(
270    path: &str,
271    state: &'static str,
272    kind: &'static str,
273    record: Option<&manifest::FileRecord>,
274    current: Option<&[u8]>,
275    history: &History,
276    target: &Utf8Path,
277    (word, replaceable): (&'static str, bool),
278) -> Row {
279    let vouched = record
280        .zip(current)
281        .map(|(record, bytes)| Digest::of(bytes) == record.sha256);
282    let commits = history
283        .available
284        .then(|| commits_touching(target, path))
285        .flatten();
286    let provenance = matches!(state, "differs" | "retired" | "dropped").then(|| {
287        let record_half = vouched == Some(true);
288        let history_half = commits.as_ref().is_some_and(|commits| commits > &0);
289        match (record_half, history_half) {
290            (true, true) => "attributed",
291            (true, false) | (false, true) => "reduced",
292            (false, false) => "unknown",
293        }
294    });
295    let edited_after_added = if record.is_none() && current.is_some() {
296        commits.and_then(|commits| match commits {
297            0 => None,
298            1 => edited_in_the_tree(target, path),
299            _ => Some(true),
300        })
301    } else {
302        None
303    };
304    Row {
305        path: path.to_owned(),
306        state,
307        kind,
308        provenance,
309        vouched,
310        edited_after_added,
311        word,
312        replaceable,
313    }
314}
315
316/// The marked region of a spliced document, by the markers its name
317/// implies.
318fn region_of(destination: &str, document: &[u8]) -> Option<Vec<u8>> {
319    let (begin, end) = landing::block_markers(destination)?;
320    landing::extract_block(&String::from_utf8_lossy(document), begin, end)
321        .map(|block| block.as_bytes().to_vec())
322}
323
324/// How many commits touched `path`, or `None` where Git cannot say.
325fn commits_touching(target: &Utf8Path, path: &str) -> Option<usize> {
326    let output = git(target)
327        .args(["log", "--format=%H", "--", path])
328        .output()
329        .ok()?;
330    output.status.success().then(|| {
331        String::from_utf8_lossy(&output.stdout)
332            .lines()
333            .filter(|line| !line.trim().is_empty())
334            .count()
335    })
336}
337
338/// Whether the work tree holds an edit to `path` past its committed
339/// bytes, or `None` where Git cannot say.
340fn edited_in_the_tree(target: &Utf8Path, path: &str) -> Option<bool> {
341    let status = git(target)
342        .args(["diff", "--quiet", "HEAD", "--", path])
343        .status()
344        .ok()?;
345    match status.code() {
346        Some(0) => Some(false),
347        Some(1) => Some(true),
348        _ => None,
349    }
350}
351
352/// A git command against the target alone: the variables a running hook
353/// exports are scrubbed, so an inherited `GIT_DIR` cannot redirect it.
354fn git(target: &Utf8Path) -> Command {
355    let mut command = Command::new(crate::probes::git_bin());
356    for var in crate::maintenance::GIT_HOOK_VARS {
357        command.env_remove(var);
358    }
359    command
360        .env("LC_ALL", "C")
361        .arg("-C")
362        .arg(Path::new(target.as_str()));
363    command
364}
365
366/// Whether every rendered destination of a report reads `equal`.
367///
368/// It is the proof after a landing. A `retired` row is a removal still to
369/// happen, so it stands against the proof as any differing file does. A
370/// dropped row is no candidate: the target keeps it and owns its bytes.
371#[must_use]
372pub fn rendered_equal(report: &Report) -> bool {
373    report
374        .rows
375        .iter()
376        .filter(|row| row.state != "dropped" && matches!(row.kind, "rendered" | "region"))
377        .all(|row| row.state == "equal")
378}
379
380#[cfg(test)]
381mod tests {
382    use super::{History, Report, Row};
383
384    /// The complete `rk.stage-compare/3` shape, held by snapshot.
385    #[test]
386    fn the_compare_report_schema_snapshot_holds() {
387        let report = Report {
388            schema: super::SCHEMA,
389            stage_root: "/state/stages/abc/0.9.0".into(),
390            target: "/srv/widget".into(),
391            history: History {
392                available: true,
393                reason: None,
394            },
395            rows: vec![
396                Row {
397                    path: ".github/workflows/release-plz.yml".into(),
398                    state: "differs",
399                    kind: "rendered",
400                    provenance: Some("reduced"),
401                    vouched: Some(false),
402                    edited_after_added: None,
403                    word: "collision",
404                    replaceable: true,
405                },
406                Row {
407                    path: "AGENTS.md".into(),
408                    state: "equal",
409                    kind: "region",
410                    provenance: None,
411                    vouched: Some(true),
412                    edited_after_added: None,
413                    word: "replaced",
414                    replaceable: false,
415                },
416                Row {
417                    path: ".github/workflows/old-release.yml".into(),
418                    state: "retired",
419                    kind: "rendered",
420                    provenance: Some("attributed"),
421                    vouched: Some(true),
422                    edited_after_added: None,
423                    word: "retired",
424                    replaceable: false,
425                },
426            ],
427        };
428        assert_eq!(
429            serde_json::to_string(&report).expect("a report serializes"),
430            r#"{"schema":"rk.stage-compare/3","stage_root":"/state/stages/abc/0.9.0","target":"/srv/widget","history":{"available":true,"reason":null},"rows":[{"path":".github/workflows/release-plz.yml","state":"differs","kind":"rendered","provenance":"reduced","vouched":false,"edited_after_added":null,"word":"collision","replaceable":true},{"path":"AGENTS.md","state":"equal","kind":"region","provenance":null,"vouched":true,"edited_after_added":null,"word":"replaced","replaceable":false},{"path":".github/workflows/old-release.yml","state":"retired","kind":"rendered","provenance":"attributed","vouched":true,"edited_after_added":null,"word":"retired","replaceable":false}]}"#
431        );
432        assert!(!super::rendered_equal(&report));
433    }
434
435    /// A dropped destination is the target's own, and a settled retirement
436    /// reads equal, so neither counts against the proof. A retirement still
437    /// to happen does.
438    #[test]
439    fn only_a_pending_retirement_of_the_dropped_rows_counts_against_the_proof() {
440        let row = |path: &str, state: &'static str, word: &'static str| Row {
441            path: path.into(),
442            state,
443            kind: "rendered",
444            provenance: None,
445            vouched: None,
446            edited_after_added: None,
447            word,
448            replaceable: false,
449        };
450        let mut report = Report {
451            schema: super::SCHEMA,
452            stage_root: "/state/stages/abc/0.9.0".into(),
453            target: "/srv/widget".into(),
454            history: History {
455                available: true,
456                reason: None,
457            },
458            rows: vec![
459                row(".github/workflows/release-plz.yml", "equal", "matched"),
460                row(".github/workflows/legacy.yml", "dropped", "dropped"),
461                row(".github/workflows/old-release.yml", "equal", "retired"),
462            ],
463        };
464        assert!(super::rendered_equal(&report));
465        report
466            .rows
467            .push(row(".github/workflows/old.yml", "retired", "retired"));
468        assert!(!super::rendered_equal(&report));
469    }
470}