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