Skip to main content

release_kit/release_engine/
packed.rs

1//! The files a release packs, and whether they changed since the last
2//! release.
3//!
4//! The request opens only where a packed file changed or the packed set
5//! did, the rule release-plz applies to a crate. The list is the union of
6//! what the release packs at `HEAD` and at the last release tag, each read
7//! through the driver adapter and each without the destinations its own
8//! revision's landing record names. A bash release packs the tracked files its declaration
9//! selects, with Cargo's `include` and `exclude` semantics, and this
10//! module packs them into a reproducible archive from the git objects. A
11//! python sdist builds in a temporary worktree outside the checkout, which
12//! is removed afterwards, also on failure.
13//!
14//! SATISFIES release-engine:a-request-follows-the-packed-files
15//! SATISFIES release-engine:the-bash-release-packs-its-declaration
16
17use std::collections::BTreeSet;
18use std::io::{BufRead as _, Read as _, Write as _};
19use std::sync::atomic::{AtomicU64, Ordering};
20
21use camino::{Utf8Path, Utf8PathBuf};
22use ignore::gitignore::{Gitignore, GitignoreBuilder};
23
24use crate::diagnostic::{Diagnostic, Reason};
25use crate::error::RkError;
26
27use super::Context;
28
29/// The key that names the tracked files a bash release ships.
30pub const INCLUDE_KEY: &str = "profile.release.build.include";
31
32/// The key that names the tracked files a bash release leaves out.
33pub const EXCLUDE_KEY: &str = "profile.release.build.exclude";
34
35/// The key that names the command run in the unpacked bash archive.
36pub const VERIFY_KEY: &str = "profile.release.build.verify";
37
38/// The bash version file, which every bash release ships, as Cargo always
39/// packs `Cargo.toml`.
40pub const VERSION_FILE: &str = "VERSION";
41
42/// Why a bash request opens nothing where the declaration selects no file
43/// yet.
44pub const NOTHING_YET: &str =
45    "no packed file exists yet: profile.release.build.include matches no tracked file";
46
47/// The directory every landing record and configuration lives in.
48const RECORD_DIR: &str = ".release-kit/";
49
50/// The files a python sdist generates rather than copies from the tree.
51const GENERATED: [&str; 2] = ["PKG-INFO", "pyproject.toml.orig"];
52
53/// What the request compared: the last release tag, the packed list, and
54/// whether the packed set or a packed file differs between the tag and
55/// `HEAD`.
56#[derive(Debug, Clone, PartialEq, Eq)]
57pub struct Comparison {
58    /// The last release tag.
59    pub tag: String,
60    /// The files the release packs at `HEAD` or at the tag, sorted.
61    pub paths: Vec<String>,
62    /// Whether any of them differs between the tag and `HEAD`, or the
63    /// two revisions pack different sets.
64    pub changed: bool,
65    /// The paths git-cliff reads the history of: the packed files, the
66    /// driver's version file, and the configuration where a change of its
67    /// declaration changed the packed set, so the commit that changed it
68    /// counts.
69    pub history: Vec<String>,
70    /// The paths whose commits the releasing-commit condition reads: the
71    /// packed files, and the configuration where a change of its
72    /// declaration changed the packed set. The version file stays out,
73    /// because the release commit alone changes it.
74    pub inputs: Vec<String>,
75}
76
77/// What a bash release ships, as Cargo's `include` and `exclude` declare
78/// a crate's package: gitignore-style patterns, where `include` overrides
79/// `exclude`, and neither key ships every tracked file.
80pub struct Declaration {
81    include: Option<Gitignore>,
82    exclude: Gitignore,
83}
84
85impl Declaration {
86    /// One pattern list as the gitignore matcher Cargo builds for it.
87    ///
88    /// # Errors
89    ///
90    /// The refusal text, naming the key and the pattern the matcher
91    /// refuses.
92    pub fn parse(key: &str, patterns: &[String]) -> Result<Gitignore, String> {
93        let mut builder = GitignoreBuilder::new("");
94        for pattern in patterns {
95            builder.add_line(None, pattern).map_err(|error| {
96                format!("{key} holds {pattern:?}, which is no gitignore-style pattern: {error}")
97            })?;
98        }
99        builder
100            .build()
101            .map_err(|error| format!("{key} does not build a matcher: {error}"))
102    }
103
104    /// The declaration of two pattern lists. An empty `include` is no
105    /// `include`, as in Cargo.
106    ///
107    /// # Errors
108    ///
109    /// The refusal text of a pattern the matcher refuses.
110    pub fn new(include: &[String], exclude: &[String]) -> Result<Self, String> {
111        Ok(Self {
112            include: if include.is_empty() {
113                None
114            } else {
115                Some(Self::parse(INCLUDE_KEY, include)?)
116            },
117            exclude: Self::parse(EXCLUDE_KEY, exclude)?,
118        })
119    }
120
121    /// The declaration a revision's `.release-kit/config.toml` records:
122    /// neither key where the revision holds no configuration or the table
123    /// names no pattern.
124    ///
125    /// # Errors
126    ///
127    /// A refusal where a recorded pattern does not parse.
128    pub fn at(root: &Utf8Path, rev: &str) -> Result<Self, RkError> {
129        let (include, exclude) = recorded(root, rev);
130        Self::new(&include, &exclude).map_err(|message| {
131            RkError::refusal(
132                Diagnostic::new(Reason::ConfigInvalid, format!("at {rev}: {message}"))
133                    .expected("gitignore-style patterns, as Cargo's include and exclude take")
134                    .target_state("unchanged"),
135            )
136        })
137    }
138
139    /// Whether an `include` pattern names the file.
140    fn names(&self, path: &str) -> bool {
141        self.include
142            .as_ref()
143            .is_some_and(|include| include.matched_path_or_any_parents(path, false).is_ignore())
144    }
145
146    /// Whether the declaration ships the tracked file `path`, as
147    /// `cargo package` decides: through `include` where it is set, and
148    /// otherwise through `exclude`, each pattern judged on the file and
149    /// on every directory above it.
150    #[must_use]
151    pub fn ships(&self, path: &str) -> bool {
152        if self.include.is_some() {
153            return self.names(path);
154        }
155        !self
156            .exclude
157            .matched_path_or_any_parents(path, false)
158            .is_ignore()
159    }
160}
161
162/// The `include` and `exclude` patterns a revision's
163/// `.release-kit/config.toml` records: none where the revision holds no
164/// configuration or the table names no pattern.
165fn recorded(root: &Utf8Path, rev: &str) -> (Vec<String>, Vec<String>) {
166    let spec = format!("{rev}:{RECORD_DIR}config.toml");
167    let text = super::git(root, &["show", &spec])
168        .ok()
169        .filter(|output| output.status.success())
170        .map(|output| String::from_utf8_lossy(&output.stdout).into_owned())
171        .unwrap_or_default();
172    let table: toml::Table = toml::from_str(&text).unwrap_or_default();
173    let build = table
174        .get("profile")
175        .and_then(|profile| profile.get("release"))
176        .and_then(|release| release.get("build"));
177    let list = |key: &str| -> Vec<String> {
178        build
179            .and_then(|build| build.get(key))
180            .and_then(toml::Value::as_array)
181            .map(|items| {
182                items
183                    .iter()
184                    .filter_map(|item| item.as_str().map(str::to_owned))
185                    .collect()
186            })
187            .unwrap_or_default()
188    };
189    (list("include"), list("exclude"))
190}
191
192/// The files a bash release ships from the tracked files of a revision.
193///
194/// They are those the declaration selects, and `VERSION` always. A
195/// destination rk's record names, and `.release-kit/`, ship only where an
196/// `include` pattern names them.
197#[must_use]
198pub fn declared(
199    tracked: &BTreeSet<String>,
200    declaration: &Declaration,
201    destinations: &BTreeSet<String>,
202) -> BTreeSet<String> {
203    tracked
204        .iter()
205        .filter(|path| {
206            if path.as_str() == VERSION_FILE {
207                return true;
208            }
209            let rk_owned = path.starts_with(RECORD_DIR) || destinations.contains(*path);
210            if rk_owned {
211                declaration.names(path)
212            } else {
213                declaration.ships(path)
214            }
215        })
216        .cloned()
217        .collect()
218}
219
220/// Whether a bash package holds nothing past the version file it always
221/// ships.
222#[must_use]
223pub fn is_empty_package(files: &BTreeSet<String>) -> bool {
224    files.iter().all(|path| path == VERSION_FILE)
225}
226
227/// The refusal of a bash release whose declaration ships no file yet.
228#[must_use]
229pub fn empty_package(rev: &str) -> RkError {
230    RkError::refusal(
231        Diagnostic::new(
232            Reason::PrerequisiteUnmet,
233            format!(
234                "the package at {rev} holds no file past {VERSION_FILE}: {INCLUDE_KEY} and {EXCLUDE_KEY} select no tracked file"
235            ),
236        )
237        .expected("a declaration that selects the tracked files the release ships")
238        .action(format!(
239            "widen {INCLUDE_KEY} or {EXCLUDE_KEY} in .release-kit/config.toml, run rk upgrade --apply, and read rk release build --list"
240        ))
241        .target_state("no artifact, upload, tag, or release"),
242    )
243}
244
245/// The members of an archive below the one top directory every member
246/// shares, where they share one; otherwise the members unchanged.
247#[must_use]
248pub fn strip_top(members: &[String]) -> Vec<String> {
249    let clean: Vec<&str> = members
250        .iter()
251        .map(|member| member.trim_start_matches("./"))
252        .filter(|member| !member.is_empty())
253        .collect();
254    let top = clean.first().and_then(|member| member.split_once('/'));
255    let shared = top.is_some_and(|(top, _)| {
256        clean.iter().all(|member| {
257            member
258                .split_once('/')
259                .is_some_and(|(first, _)| first == top)
260        })
261    });
262    clean
263        .iter()
264        .map(|member| {
265            if shared {
266                member.split_once('/').map_or(*member, |(_, rest)| rest)
267            } else {
268                member
269            }
270        })
271        .filter(|member| !member.is_empty() && !member.ends_with('/'))
272        .map(str::to_owned)
273        .collect()
274}
275
276/// The members without the files a python sdist generates at its root.
277#[must_use]
278pub fn drop_generated(members: Vec<String>) -> Vec<String> {
279    members
280        .into_iter()
281        .filter(|member| !GENERATED.contains(&member.as_str()))
282        .collect()
283}
284
285/// The members that are tracked files of the revision.
286#[must_use]
287pub fn keep_tracked(members: Vec<String>, tracked: &BTreeSet<String>) -> BTreeSet<String> {
288    members
289        .into_iter()
290        .filter(|member| tracked.contains(member))
291        .collect()
292}
293
294/// The list without the files rk owns at that revision: every destination
295/// its landing record names, and the record's own directory.
296#[must_use]
297pub fn subtract(list: BTreeSet<String>, destinations: &BTreeSet<String>) -> BTreeSet<String> {
298    list.into_iter()
299        .filter(|path| !path.starts_with(RECORD_DIR) && !destinations.contains(path))
300        .collect()
301}
302
303/// A path as a git-cliff `--include-path` pattern that matches it alone:
304/// each glob metacharacter stands inside brackets.
305#[must_use]
306pub fn cliff_pattern(path: &str) -> String {
307    let mut pattern = String::with_capacity(path.len());
308    for c in path.chars() {
309        if matches!(c, '*' | '?' | '[' | ']') {
310            pattern.push('[');
311            pattern.push(c);
312            pattern.push(']');
313        } else {
314            pattern.push(c);
315        }
316    }
317    pattern
318}
319
320/// The file members of a gzip tarball, as the archive names them.
321///
322/// # Errors
323///
324/// A read of an archive that is no gzip tarball.
325pub fn members(archive: &Utf8Path) -> Result<Vec<String>, RkError> {
326    let unreadable = |source: std::io::Error| {
327        RkError::check_failed(
328            Diagnostic::new(
329                Reason::PostconditionFailed,
330                format!("{archive} is not a gzip tarball rk can read: {source}"),
331            )
332            .expected("a .tar.gz the build wrote")
333            .target_state("unchanged"),
334        )
335    };
336    let file = std::fs::File::open(archive).map_err(RkError::Io)?;
337    let mut tarball = tar::Archive::new(flate2::read::GzDecoder::new(file));
338    let mut found = Vec::new();
339    for entry in tarball.entries().map_err(unreadable)? {
340        let entry = entry.map_err(unreadable)?;
341        let kind = entry.header().entry_type();
342        if !(kind.is_file() || kind.is_symlink() || kind.is_hard_link()) {
343            continue;
344        }
345        let path = entry.path().map_err(unreadable)?;
346        found.push(path.to_string_lossy().into_owned());
347    }
348    Ok(found)
349}
350
351/// One tracked file of a revision's tree: its git mode, its blob, and
352/// its path.
353#[derive(Debug, Clone, PartialEq, Eq)]
354pub struct Entry {
355    /// The git mode: `100644`, `100755`, or `120000` for a symlink.
356    pub mode: String,
357    /// The blob's object name.
358    pub object: String,
359    /// The path, relative to the root.
360    pub path: String,
361}
362
363/// The tracked files of a revision's tree, sorted by path. A submodule is
364/// a commit and no file of this tree, so it is left out.
365///
366/// # Errors
367///
368/// git's failure.
369pub fn entries(root: &Utf8Path, rev: &str) -> Result<Vec<Entry>, RkError> {
370    let listed = super::git_ok(
371        root,
372        &["ls-tree", "-r", "-z", "--full-tree", rev],
373        "the revision's tree",
374    )?;
375    let mut found: Vec<Entry> = listed
376        .split('\0')
377        .filter_map(|line| {
378            let (meta, path) = line.split_once('\t')?;
379            let mut parts = meta.split(' ');
380            let (mode, kind, object) = (parts.next()?, parts.next()?, parts.next()?);
381            (kind == "blob").then(|| Entry {
382                mode: mode.to_owned(),
383                object: object.to_owned(),
384                path: path.to_owned(),
385            })
386        })
387        .collect();
388    found.sort_by(|a, b| a.path.cmp(&b.path));
389    Ok(found)
390}
391
392/// The tracked files of a revision.
393///
394/// # Errors
395///
396/// git's failure.
397pub fn tracked(root: &Utf8Path, rev: &str) -> Result<BTreeSet<String>, RkError> {
398    Ok(entries(root, rev)?
399        .into_iter()
400        .map(|entry| entry.path)
401        .collect())
402}
403
404/// The files a bash release ships at a revision: the tracked files the
405/// declaration of that revision selects, with `VERSION`.
406///
407/// # Errors
408///
409/// git's failure, or a recorded pattern that does not parse.
410pub fn bash_set(root: &Utf8Path, rev: &str) -> Result<BTreeSet<String>, RkError> {
411    Ok(declared(
412        &tracked(root, rev)?,
413        &Declaration::at(root, rev)?,
414        &destinations(root, rev),
415    ))
416}
417
418/// The bytes of every blob, in order, through one `git cat-file --batch`.
419fn blobs(root: &Utf8Path, objects: &[&str]) -> Result<Vec<Vec<u8>>, RkError> {
420    let failed = |message: String| {
421        RkError::subprocess(
422            Diagnostic::new(Reason::SubprocessFailed, message)
423                .expected("git cat-file to read every packed blob"),
424        )
425    };
426    let mut command = std::process::Command::new(crate::probes::git_bin());
427    for var in crate::maintenance::GIT_HOOK_VARS {
428        command.env_remove(var);
429    }
430    let mut child = command
431        .arg("-C")
432        .arg(root.as_std_path())
433        .args(["cat-file", "--batch"])
434        .stdin(std::process::Stdio::piped())
435        .stdout(std::process::Stdio::piped())
436        .stderr(std::process::Stdio::null())
437        .spawn()
438        .map_err(|source| failed(format!("git did not run: {source}")))?;
439    let mut stdin = child
440        .stdin
441        .take()
442        .ok_or_else(|| failed("git cat-file took no input".to_owned()))?;
443    let mut input = String::new();
444    for object in objects {
445        input.push_str(object);
446        input.push('\n');
447    }
448    let writer = std::thread::spawn(move || stdin.write_all(input.as_bytes()));
449    let mut reader = std::io::BufReader::new(
450        child
451            .stdout
452            .take()
453            .ok_or_else(|| failed("git cat-file gave no output".to_owned()))?,
454    );
455    let mut out = Vec::with_capacity(objects.len());
456    for object in objects {
457        let mut header = String::new();
458        reader.read_line(&mut header).map_err(RkError::Io)?;
459        let size = header
460            .split(' ')
461            .nth(2)
462            .and_then(|size| size.trim().parse::<usize>().ok())
463            .ok_or_else(|| {
464                failed(format!(
465                    "git cat-file did not read {object}: {}",
466                    header.trim()
467                ))
468            })?;
469        let mut bytes = vec![0; size];
470        reader.read_exact(&mut bytes).map_err(RkError::Io)?;
471        let mut newline = [0; 1];
472        reader.read_exact(&mut newline).map_err(RkError::Io)?;
473        out.push(bytes);
474    }
475    writer
476        .join()
477        .map_err(|_| failed("git cat-file's input stopped".to_owned()))?
478        .map_err(RkError::Io)?;
479    child.wait().map_err(RkError::Io)?;
480    Ok(out)
481}
482
483/// Pack `files` of the revision `rev` into the gzip tarball `archive`,
484/// under the one top directory `top`, from the git objects alone.
485///
486/// The archive is reproducible: members sorted by path, no directory
487/// entry, each mode from its git mode, owner and group 0 with empty
488/// names, every time `epoch`, and a gzip header with no name and a zero
489/// time, so a build of one commit writes the same bytes on every host.
490///
491/// # Errors
492///
493/// git's failure, or a write that fails.
494pub fn pack(
495    root: &Utf8Path,
496    rev: &str,
497    files: &BTreeSet<String>,
498    top: &str,
499    epoch: u64,
500    archive: &Utf8Path,
501) -> Result<(), RkError> {
502    let shipped: Vec<Entry> = entries(root, rev)?
503        .into_iter()
504        .filter(|entry| files.contains(&entry.path))
505        .collect();
506    let objects: Vec<&str> = shipped.iter().map(|entry| entry.object.as_str()).collect();
507    let contents = blobs(root, &objects)?;
508    let file = std::fs::File::create(archive).map_err(RkError::Io)?;
509    let gzip = flate2::GzBuilder::new()
510        .mtime(0)
511        .write(file, flate2::Compression::default());
512    let mut tarball = tar::Builder::new(gzip);
513    for (entry, bytes) in shipped.iter().zip(contents) {
514        let path = format!("{top}/{}", entry.path);
515        let mut header = tar::Header::new_gnu();
516        header.set_uid(0);
517        header.set_gid(0);
518        header.set_username("").map_err(RkError::Io)?;
519        header.set_groupname("").map_err(RkError::Io)?;
520        header.set_mtime(epoch);
521        if entry.mode == "120000" {
522            header.set_entry_type(tar::EntryType::Symlink);
523            header.set_mode(0o777);
524            header.set_size(0);
525            let target = String::from_utf8_lossy(&bytes).into_owned();
526            tarball
527                .append_link(&mut header, &path, &target)
528                .map_err(RkError::Io)?;
529        } else {
530            header.set_entry_type(tar::EntryType::Regular);
531            header.set_mode(if entry.mode == "100755" { 0o755 } else { 0o644 });
532            header.set_size(bytes.len() as u64);
533            tarball
534                .append_data(&mut header, &path, bytes.as_slice())
535                .map_err(RkError::Io)?;
536        }
537    }
538    tarball
539        .into_inner()
540        .map_err(RkError::Io)?
541        .finish()
542        .map_err(RkError::Io)?;
543    Ok(())
544}
545
546/// Unpack the gzip tarball `archive` into `dir`.
547///
548/// # Errors
549///
550/// A read or a write that fails.
551pub fn unpack(archive: &Utf8Path, dir: &Utf8Path) -> Result<(), RkError> {
552    let file = std::fs::File::open(archive).map_err(RkError::Io)?;
553    tar::Archive::new(flate2::read::GzDecoder::new(file))
554        .unpack(dir)
555        .map_err(RkError::Io)
556}
557
558/// The destinations the landing record names at a revision: none where
559/// the revision holds no record.
560#[must_use]
561pub fn destinations(root: &Utf8Path, rev: &str) -> BTreeSet<String> {
562    let spec = format!("{rev}:{RECORD_DIR}manifest.json");
563    let Ok(output) = super::git(root, &["show", &spec]) else {
564        return BTreeSet::new();
565    };
566    if !output.status.success() {
567        return BTreeSet::new();
568    }
569    serde_json::from_slice::<serde_json::Value>(&output.stdout)
570        .ok()
571        .and_then(|record| record.get("files").cloned())
572        .and_then(|files| files.as_array().cloned())
573        .unwrap_or_default()
574        .iter()
575        .filter_map(|file| file.get("destination")?.as_str().map(str::to_owned))
576        .collect()
577}
578
579/// The commit time of a revision, for `SOURCE_DATE_EPOCH`.
580///
581/// # Errors
582///
583/// git's failure.
584pub fn epoch(root: &Utf8Path, rev: &str) -> Result<String, RkError> {
585    super::git_line(
586        root,
587        &["log", "-1", "--format=%ct", rev],
588        "the revision's commit time",
589    )
590}
591
592/// The refusal where a python sdist holds no file git tracks.
593#[must_use]
594pub fn mirrors_nothing(what: &str, rev: &str) -> RkError {
595    RkError::refusal(
596        Diagnostic::new(
597            Reason::PrerequisiteUnmet,
598            format!(
599                "{what} at {rev} holds no file git tracks there, so the request cannot tell which files the release packs"
600            ),
601        )
602        .expected("a package whose members, below one top directory, are tracked files")
603        .action("configure the build backend to pack the project's tracked files, then read the sdist with tar -tzf")
604        .target_state("unchanged"),
605    )
606}
607
608/// A fresh directory under the system's temporary directory, removed
609/// when it drops, also on failure.
610pub struct TempDir {
611    dir: Utf8PathBuf,
612}
613
614impl TempDir {
615    /// Create one, named for `purpose`.
616    ///
617    /// # Errors
618    ///
619    /// A temporary directory that is not UTF-8, or that cannot be created.
620    pub fn new(purpose: &str) -> Result<Self, RkError> {
621        static COUNT: AtomicU64 = AtomicU64::new(0);
622        let base = Utf8PathBuf::from_path_buf(std::env::temp_dir()).map_err(|path| {
623            RkError::refusal(
624                Diagnostic::new(
625                    Reason::PrerequisiteUnmet,
626                    format!("the temporary directory {} is not UTF-8", path.display()),
627                )
628                .expected("a UTF-8 temporary directory")
629                .target_state("unchanged"),
630            )
631        })?;
632        let nanos = std::time::SystemTime::now()
633            .duration_since(std::time::UNIX_EPOCH)
634            .map_or(0, |elapsed| elapsed.subsec_nanos());
635        let dir = base.join(format!(
636            "rk-{purpose}-{}-{nanos}-{}",
637            std::process::id(),
638            COUNT.fetch_add(1, Ordering::Relaxed)
639        ));
640        std::fs::create_dir_all(&dir).map_err(RkError::Io)?;
641        Ok(Self { dir })
642    }
643
644    /// The directory.
645    #[must_use]
646    pub fn path(&self) -> &Utf8Path {
647        &self.dir
648    }
649}
650
651impl Drop for TempDir {
652    fn drop(&mut self) {
653        let _ = std::fs::remove_dir_all(&self.dir);
654    }
655}
656
657/// A temporary worktree of one revision, outside the checkout, removed
658/// when it drops, also on failure.
659pub struct Scratch {
660    root: Utf8PathBuf,
661    dir: TempDir,
662}
663
664impl Scratch {
665    /// Check out `rev` into a fresh directory under the system's temporary
666    /// directory, with no hook run.
667    ///
668    /// # Errors
669    ///
670    /// A directory that cannot be created, or git's failure.
671    pub fn new(root: &Utf8Path, rev: &str) -> Result<Self, RkError> {
672        let scratch = Self {
673            root: root.to_owned(),
674            dir: TempDir::new("packed")?,
675        };
676        let tree = scratch.tree();
677        super::git_ok(
678            root,
679            &[
680                "-c",
681                "core.hooksPath=/dev/null",
682                "worktree",
683                "add",
684                "--detach",
685                "-q",
686                tree.as_str(),
687                rev,
688            ],
689            "a temporary worktree of the revision",
690        )?;
691        Ok(scratch)
692    }
693
694    /// The worktree.
695    #[must_use]
696    pub fn tree(&self) -> Utf8PathBuf {
697        self.dir.path().join("tree")
698    }
699
700    /// A directory beside the worktree, outside it, for build output.
701    #[must_use]
702    pub fn out(&self) -> Utf8PathBuf {
703        self.dir.path().join("out")
704    }
705}
706
707impl Drop for Scratch {
708    fn drop(&mut self) {
709        let tree = self.tree();
710        let _ = super::git(
711            &self.root,
712            &["worktree", "remove", "--force", tree.as_str()],
713        );
714        let _ = std::fs::remove_dir_all(&tree);
715        let _ = super::git(&self.root, &["worktree", "prune"]);
716    }
717}
718
719/// The packed files of one revision, without rk's own.
720fn side(context: &Context, rev: &str) -> Result<BTreeSet<String>, RkError> {
721    let listed = context.adapter.packed(&context.target, rev)?;
722    Ok(subtract(listed, &destinations(&context.target, rev)))
723}
724
725/// Compare the packed files of `HEAD` with those of the release `tag`.
726///
727/// # Errors
728///
729/// A refusal where the tag is not in the checkout or the release packs no
730/// file, and a build or git failure.
731pub fn compare(context: &Context, tag: &str) -> Result<Comparison, RkError> {
732    let root = &context.target;
733    let at = format!("refs/tags/{tag}^{{commit}}");
734    let held = super::git(root, &["rev-parse", "--verify", "-q", &at])?;
735    if !held.status.success() {
736        return Err(RkError::refusal(
737            Diagnostic::new(
738                Reason::ObservationIncomplete,
739                format!("the release tag {tag} is not in the checkout"),
740            )
741            .expected("a checkout that holds every release tag, as fetch-depth 0 fetches them")
742            .target_state("unchanged"),
743        ));
744    }
745    let at_head = side(context, "HEAD")?;
746    let released = side(context, tag)?;
747    // The set counts as a set, as Cargo counts the include and exclude
748    // fields of Cargo.toml, which every crate packs: a file that joins or
749    // leaves the package changes it, unchanged as its bytes may be.
750    let moved = at_head != released;
751    let declared = moved && recorded(root, "HEAD") != recorded(root, tag);
752    let mut list = at_head;
753    list.extend(released);
754    // A declaration that selects nothing yet is valid: the request names
755    // it and opens nothing, and git diff with no path would read the
756    // whole tree.
757    if list.is_empty() {
758        return Ok(Comparison {
759            tag: tag.to_owned(),
760            paths: Vec::new(),
761            changed: false,
762            history: Vec::new(),
763            inputs: Vec::new(),
764        });
765    }
766    let paths: Vec<String> = list.into_iter().collect();
767    let changed = moved || differs(root, tag, &paths)?;
768    let mut history = paths.clone();
769    let mut inputs = paths.clone();
770    // git-cliff 2.14.2 filters the commits by path before it reads the
771    // release tags, so a tag on a commit outside the paths no longer bounds
772    // the history, and the bump answers the released version. Every release
773    // commit changes the version file, so that file keeps the last release
774    // in view; cliff.toml skips the release commit itself.
775    if let Some((file, _)) = super::adapter::version_file(context.adapter.driver())
776        && !history.iter().any(|path| path == file)
777    {
778        history.push(file.to_owned());
779    }
780    if declared {
781        history.push(format!("{RECORD_DIR}config.toml"));
782        inputs.push(format!("{RECORD_DIR}config.toml"));
783    }
784    Ok(Comparison {
785        tag: tag.to_owned(),
786        paths,
787        changed,
788        history,
789        inputs,
790    })
791}
792
793/// Whether any of `paths` differs between `tag` and `HEAD`, as
794/// `git diff --quiet` answers.
795///
796/// # Errors
797///
798/// git's failure to compare.
799pub fn differs(root: &Utf8Path, tag: &str, paths: &[String]) -> Result<bool, RkError> {
800    let mut args = vec![
801        "--literal-pathspecs",
802        "diff",
803        "--quiet",
804        "--no-ext-diff",
805        "--no-textconv",
806        tag,
807        "HEAD",
808        "--",
809    ];
810    args.extend(paths.iter().map(String::as_str));
811    let output = super::git(root, &args)?;
812    match output.status.code() {
813        Some(0) => Ok(false),
814        Some(1) => Ok(true),
815        _ => Err(RkError::subprocess(
816            Diagnostic::new(Reason::SubprocessFailed, super::last_line(&output.stderr))
817                .expected("git to compare the packed files"),
818        )),
819    }
820}
821
822/// The files the release packs at `HEAD`, without rk's own, sorted: the
823/// list a first release, with no release tag to compare with, reads the
824/// commits of.
825///
826/// # Errors
827///
828/// A build or git failure.
829pub fn at_head(context: &Context) -> Result<Vec<String>, RkError> {
830    Ok(side(context, "HEAD")?.into_iter().collect())
831}
832
833/// The newest stable release tag below `HEAD`, other than `exclude`,
834/// where one exists: `v` and a digit, with no prerelease part.
835///
836/// A candidate's tag never bounds a judgment, so the releasing commits
837/// behind a promoted candidate still count since the last stable release.
838///
839/// # Errors
840///
841/// git's failure to run.
842pub fn stable_tag_below(root: &Utf8Path, exclude: Option<&str>) -> Result<Option<String>, RkError> {
843    let mut args = vec![
844        "describe",
845        "--tags",
846        "--abbrev=0",
847        "--match",
848        "v[0-9]*",
849        "--exclude",
850        "*-*",
851    ];
852    if let Some(tag) = exclude {
853        args.extend(["--exclude", tag]);
854    }
855    args.push("HEAD");
856    let output = super::git(root, &args)?;
857    Ok(output
858        .status
859        .success()
860        .then(|| String::from_utf8_lossy(&output.stdout).trim().to_owned())
861        .filter(|found| !found.is_empty()))
862}
863
864/// The newest release tag below `HEAD` other than `tag`, where one exists.
865///
866/// # Errors
867///
868/// git's failure to run.
869pub fn previous_tag(root: &Utf8Path, tag: &str) -> Result<Option<String>, RkError> {
870    let output = super::git(
871        root,
872        &[
873            "describe",
874            "--tags",
875            "--abbrev=0",
876            "--match",
877            "v[0-9]*",
878            "--exclude",
879            tag,
880            "HEAD",
881        ],
882    )?;
883    Ok(output
884        .status
885        .success()
886        .then(|| String::from_utf8_lossy(&output.stdout).trim().to_owned())
887        .filter(|found| !found.is_empty()))
888}
889
890#[cfg(test)]
891mod tests {
892    use std::collections::BTreeSet;
893
894    use super::{cliff_pattern, drop_generated, keep_tracked, strip_top, subtract};
895
896    fn owned(paths: &[&str]) -> Vec<String> {
897        paths.iter().map(|path| (*path).to_owned()).collect()
898    }
899
900    fn set(paths: &[&str]) -> BTreeSet<String> {
901        owned(paths).into_iter().collect()
902    }
903
904    /// SATISFIES release-engine:a-request-follows-the-packed-files
905    ///
906    /// The list arithmetic: the shared top directory strips, the generated
907    /// sdist files drop, only tracked files stay, the two revisions unite,
908    /// and rk's own destinations and record directory leave each side.
909    #[test]
910    fn the_packed_list_arithmetic_holds() {
911        assert_eq!(
912            strip_top(&owned(&[
913                "acme-0.1.0/",
914                "acme-0.1.0/src/a.py",
915                "acme-0.1.0/PKG-INFO"
916            ])),
917            owned(&["src/a.py", "PKG-INFO"])
918        );
919        assert_eq!(
920            strip_top(&owned(&["./widget/README.md", "./widget/bin/tool"])),
921            owned(&["README.md", "bin/tool"])
922        );
923        assert_eq!(
924            strip_top(&owned(&["README.md", "src/tool.sh"])),
925            owned(&["README.md", "src/tool.sh"]),
926            "members with no shared top directory stay whole"
927        );
928        assert_eq!(strip_top(&owned(&["a/x", "b/y"])), owned(&["a/x", "b/y"]));
929        assert_eq!(
930            drop_generated(owned(&[
931                "PKG-INFO",
932                "pyproject.toml.orig",
933                "pyproject.toml",
934                "src/PKG-INFO"
935            ])),
936            owned(&["pyproject.toml", "src/PKG-INFO"])
937        );
938        let tracked = set(&["pyproject.toml", "src/a.py", "Makefile"]);
939        assert_eq!(
940            keep_tracked(
941                owned(&["pyproject.toml", "src/a.py", "build/out.bin"]),
942                &tracked
943            ),
944            set(&["pyproject.toml", "src/a.py"])
945        );
946        let head = subtract(
947            set(&[
948                ".gitlab-ci.yml",
949                ".release-kit/config.toml",
950                "src/a.py",
951                "cliff.toml",
952            ]),
953            &set(&[".gitlab-ci.yml", "cliff.toml"]),
954        );
955        let at_tag = subtract(
956            set(&[".gitlab-ci.yml", "old.py", "cliff.toml"]),
957            &set(&["cliff.toml"]),
958        );
959        let mut union = head;
960        union.extend(at_tag);
961        assert_eq!(
962            union,
963            set(&[".gitlab-ci.yml", "old.py", "src/a.py"]),
964            "a destination the tag's record does not name counts from the tag's side"
965        );
966        assert_eq!(cliff_pattern("src/a[1]*.py"), "src/a[[]1[]][*].py");
967        assert_eq!(cliff_pattern("src/tool.sh"), "src/tool.sh");
968    }
969
970    /// The set a declaration selects from a tracked tree.
971    fn selected(
972        include: &[&str],
973        exclude: &[&str],
974        tracked: &[&str],
975        recorded: &[&str],
976    ) -> BTreeSet<String> {
977        let declaration =
978            super::Declaration::new(&owned(include), &owned(exclude)).expect("the patterns parse");
979        super::declared(&set(tracked), &declaration, &set(recorded))
980    }
981
982    /// SATISFIES release-engine:the-bash-release-packs-its-declaration
983    ///
984    /// Cargo's `include` and `exclude` semantics: a name matches at any
985    /// depth, a leading `/` anchors it at the root, a trailing `/` matches a
986    /// directory, `**` crosses directories, `!` inside `include` carves out
987    /// an exception, `include` overrides `exclude`, and with neither key
988    /// every tracked file ships. Only tracked files ship, `VERSION` always
989    /// ships, and rk's destinations and `.release-kit/` ship only where an
990    /// `include` pattern names them.
991    #[test]
992    fn the_declaration_follows_cargo_semantics() {
993        let tree = [
994            "README.md",
995            "VERSION",
996            "Makefile",
997            "docs/README.md",
998            "docs/guide.md",
999            "src/tool.sh",
1000            "src/lib/util.sh",
1001            "src/lib/test_util.sh",
1002            "tests/check.sh",
1003            "nested/tests/data.txt",
1004            "SECURITY.md",
1005            ".gitlab-ci.yml",
1006            ".release-kit/config.toml",
1007            ".release-kit/manifest.json",
1008        ];
1009        let recorded = [".gitlab-ci.yml", "SECURITY.md", "VERSION"];
1010        let all = |paths: &[&str]| set(paths);
1011        // Neither key: every tracked file, without rk's own, with VERSION.
1012        assert_eq!(
1013            selected(&[], &[], &tree, &recorded),
1014            all(&[
1015                "README.md",
1016                "VERSION",
1017                "Makefile",
1018                "docs/README.md",
1019                "docs/guide.md",
1020                "src/tool.sh",
1021                "src/lib/util.sh",
1022                "src/lib/test_util.sh",
1023                "tests/check.sh",
1024                "nested/tests/data.txt",
1025            ])
1026        );
1027        // A bare name matches at any depth; a leading slash only at the root.
1028        let any_depth = selected(&[], &["README.md"], &tree, &recorded);
1029        assert!(!any_depth.contains("README.md") && !any_depth.contains("docs/README.md"));
1030        let anchored = selected(&[], &["/README.md"], &tree, &recorded);
1031        assert!(!anchored.contains("README.md") && anchored.contains("docs/README.md"));
1032        // A trailing slash matches a directory, at any depth unless anchored.
1033        let dirs = selected(&[], &["tests/"], &tree, &recorded);
1034        assert!(!dirs.contains("tests/check.sh") && !dirs.contains("nested/tests/data.txt"));
1035        let root_dir = selected(&[], &["/tests/"], &tree, &recorded);
1036        assert!(!root_dir.contains("tests/check.sh") && root_dir.contains("nested/tests/data.txt"));
1037        // `**` crosses directories, and `*` stays within one.
1038        let deep = selected(&["src/**/*.sh"], &[], &tree, &recorded);
1039        assert_eq!(
1040            deep,
1041            all(&[
1042                "VERSION",
1043                "src/tool.sh",
1044                "src/lib/util.sh",
1045                "src/lib/test_util.sh"
1046            ])
1047        );
1048        let shallow = selected(&["/src/*.sh"], &[], &tree, &recorded);
1049        assert_eq!(shallow, all(&["VERSION", "src/tool.sh"]));
1050        // `!` inside include carves out an exception.
1051        assert_eq!(
1052            selected(&["src/", "!test_*.sh"], &[], &tree, &recorded),
1053            all(&["VERSION", "src/tool.sh", "src/lib/util.sh"])
1054        );
1055        // Include overrides exclude.
1056        assert_eq!(
1057            selected(&["src/"], &["src/"], &tree, &recorded),
1058            all(&[
1059                "VERSION",
1060                "src/tool.sh",
1061                "src/lib/util.sh",
1062                "src/lib/test_util.sh"
1063            ])
1064        );
1065        // Untracked files never ship: the set comes from the tree alone.
1066        assert!(!selected(&["*.txt"], &[], &tree, &recorded).contains("notes.txt"));
1067        // VERSION ships even where every pattern leaves it out.
1068        assert!(selected(&[], &["VERSION"], &tree, &recorded).contains("VERSION"));
1069        assert!(selected(&["src/"], &[], &tree, &recorded).contains("VERSION"));
1070        // rk's own files ship only where include names them.
1071        assert!(!selected(&[], &[], &tree, &recorded).contains("SECURITY.md"));
1072        assert!(!selected(&["*.md"], &[], &tree, &recorded).contains(".release-kit/config.toml"));
1073        let named = selected(&["/SECURITY.md", "src/"], &[], &tree, &recorded);
1074        assert!(named.contains("SECURITY.md") && !named.contains(".gitlab-ci.yml"));
1075        assert!(
1076            selected(&[".release-kit/"], &[], &tree, &recorded)
1077                .contains(".release-kit/config.toml")
1078        );
1079        // A pattern that matches nothing selects VERSION alone.
1080        assert_eq!(
1081            selected(&["/lib/"], &[], &tree, &recorded),
1082            all(&["VERSION"])
1083        );
1084    }
1085}