use std::collections::BTreeSet;
use std::io::{BufRead as _, Read as _, Write as _};
use std::sync::atomic::{AtomicU64, Ordering};
use camino::{Utf8Path, Utf8PathBuf};
use ignore::gitignore::{Gitignore, GitignoreBuilder};
use crate::diagnostic::{Diagnostic, Reason};
use crate::error::RkError;
use super::Context;
pub const INCLUDE_KEY: &str = "profile.release.build.include";
pub const EXCLUDE_KEY: &str = "profile.release.build.exclude";
pub const VERIFY_KEY: &str = "profile.release.build.verify";
pub const VERSION_FILE: &str = "VERSION";
pub const NOTHING_YET: &str =
"no packed file exists yet: profile.release.build.include matches no tracked file";
const RECORD_DIR: &str = ".release-kit/";
const GENERATED: [&str; 2] = ["PKG-INFO", "pyproject.toml.orig"];
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Comparison {
pub tag: String,
pub paths: Vec<String>,
pub changed: bool,
pub history: Vec<String>,
pub inputs: Vec<String>,
}
pub struct Declaration {
include: Option<Gitignore>,
exclude: Gitignore,
}
impl Declaration {
pub fn parse(key: &str, patterns: &[String]) -> Result<Gitignore, String> {
let mut builder = GitignoreBuilder::new("");
for pattern in patterns {
builder.add_line(None, pattern).map_err(|error| {
format!("{key} holds {pattern:?}, which is no gitignore-style pattern: {error}")
})?;
}
builder
.build()
.map_err(|error| format!("{key} does not build a matcher: {error}"))
}
pub fn new(include: &[String], exclude: &[String]) -> Result<Self, String> {
Ok(Self {
include: if include.is_empty() {
None
} else {
Some(Self::parse(INCLUDE_KEY, include)?)
},
exclude: Self::parse(EXCLUDE_KEY, exclude)?,
})
}
pub fn at(root: &Utf8Path, rev: &str) -> Result<Self, RkError> {
let (include, exclude) = recorded(root, rev);
Self::new(&include, &exclude).map_err(|message| {
RkError::refusal(
Diagnostic::new(Reason::ConfigInvalid, format!("at {rev}: {message}"))
.expected("gitignore-style patterns, as Cargo's include and exclude take")
.target_state("unchanged"),
)
})
}
fn names(&self, path: &str) -> bool {
self.include
.as_ref()
.is_some_and(|include| include.matched_path_or_any_parents(path, false).is_ignore())
}
#[must_use]
pub fn ships(&self, path: &str) -> bool {
if self.include.is_some() {
return self.names(path);
}
!self
.exclude
.matched_path_or_any_parents(path, false)
.is_ignore()
}
}
fn recorded(root: &Utf8Path, rev: &str) -> (Vec<String>, Vec<String>) {
let spec = format!("{rev}:{RECORD_DIR}config.toml");
let text = super::git(root, &["show", &spec])
.ok()
.filter(|output| output.status.success())
.map(|output| String::from_utf8_lossy(&output.stdout).into_owned())
.unwrap_or_default();
let table: toml::Table = toml::from_str(&text).unwrap_or_default();
let build = table
.get("profile")
.and_then(|profile| profile.get("release"))
.and_then(|release| release.get("build"));
let list = |key: &str| -> Vec<String> {
build
.and_then(|build| build.get(key))
.and_then(toml::Value::as_array)
.map(|items| {
items
.iter()
.filter_map(|item| item.as_str().map(str::to_owned))
.collect()
})
.unwrap_or_default()
};
(list("include"), list("exclude"))
}
#[must_use]
pub fn declared(
tracked: &BTreeSet<String>,
declaration: &Declaration,
destinations: &BTreeSet<String>,
) -> BTreeSet<String> {
tracked
.iter()
.filter(|path| {
if path.as_str() == VERSION_FILE {
return true;
}
let rk_owned = path.starts_with(RECORD_DIR) || destinations.contains(*path);
if rk_owned {
declaration.names(path)
} else {
declaration.ships(path)
}
})
.cloned()
.collect()
}
#[must_use]
pub fn is_empty_package(files: &BTreeSet<String>) -> bool {
files.iter().all(|path| path == VERSION_FILE)
}
#[must_use]
pub fn empty_package(rev: &str) -> RkError {
RkError::refusal(
Diagnostic::new(
Reason::PrerequisiteUnmet,
format!(
"the package at {rev} holds no file past {VERSION_FILE}: {INCLUDE_KEY} and {EXCLUDE_KEY} select no tracked file"
),
)
.expected("a declaration that selects the tracked files the release ships")
.action(format!(
"widen {INCLUDE_KEY} or {EXCLUDE_KEY} in .release-kit/config.toml, run rk upgrade --apply, and read rk release build --list"
))
.target_state("no artifact, upload, tag, or release"),
)
}
#[must_use]
pub fn strip_top(members: &[String]) -> Vec<String> {
let clean: Vec<&str> = members
.iter()
.map(|member| member.trim_start_matches("./"))
.filter(|member| !member.is_empty())
.collect();
let top = clean.first().and_then(|member| member.split_once('/'));
let shared = top.is_some_and(|(top, _)| {
clean.iter().all(|member| {
member
.split_once('/')
.is_some_and(|(first, _)| first == top)
})
});
clean
.iter()
.map(|member| {
if shared {
member.split_once('/').map_or(*member, |(_, rest)| rest)
} else {
member
}
})
.filter(|member| !member.is_empty() && !member.ends_with('/'))
.map(str::to_owned)
.collect()
}
#[must_use]
pub fn drop_generated(members: Vec<String>) -> Vec<String> {
members
.into_iter()
.filter(|member| !GENERATED.contains(&member.as_str()))
.collect()
}
#[must_use]
pub fn keep_tracked(members: Vec<String>, tracked: &BTreeSet<String>) -> BTreeSet<String> {
members
.into_iter()
.filter(|member| tracked.contains(member))
.collect()
}
#[must_use]
pub fn subtract(list: BTreeSet<String>, destinations: &BTreeSet<String>) -> BTreeSet<String> {
list.into_iter()
.filter(|path| !path.starts_with(RECORD_DIR) && !destinations.contains(path))
.collect()
}
#[must_use]
pub fn cliff_pattern(path: &str) -> String {
let mut pattern = String::with_capacity(path.len());
for c in path.chars() {
if matches!(c, '*' | '?' | '[' | ']') {
pattern.push('[');
pattern.push(c);
pattern.push(']');
} else {
pattern.push(c);
}
}
pattern
}
pub fn members(archive: &Utf8Path) -> Result<Vec<String>, RkError> {
let unreadable = |source: std::io::Error| {
RkError::check_failed(
Diagnostic::new(
Reason::PostconditionFailed,
format!("{archive} is not a gzip tarball rk can read: {source}"),
)
.expected("a .tar.gz the build wrote")
.target_state("unchanged"),
)
};
let file = std::fs::File::open(archive).map_err(RkError::Io)?;
let mut tarball = tar::Archive::new(flate2::read::GzDecoder::new(file));
let mut found = Vec::new();
for entry in tarball.entries().map_err(unreadable)? {
let entry = entry.map_err(unreadable)?;
let kind = entry.header().entry_type();
if !(kind.is_file() || kind.is_symlink() || kind.is_hard_link()) {
continue;
}
let path = entry.path().map_err(unreadable)?;
found.push(path.to_string_lossy().into_owned());
}
Ok(found)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Entry {
pub mode: String,
pub object: String,
pub path: String,
}
pub fn entries(root: &Utf8Path, rev: &str) -> Result<Vec<Entry>, RkError> {
let listed = super::git_ok(
root,
&["ls-tree", "-r", "-z", "--full-tree", rev],
"the revision's tree",
)?;
let mut found: Vec<Entry> = listed
.split('\0')
.filter_map(|line| {
let (meta, path) = line.split_once('\t')?;
let mut parts = meta.split(' ');
let (mode, kind, object) = (parts.next()?, parts.next()?, parts.next()?);
(kind == "blob").then(|| Entry {
mode: mode.to_owned(),
object: object.to_owned(),
path: path.to_owned(),
})
})
.collect();
found.sort_by(|a, b| a.path.cmp(&b.path));
Ok(found)
}
pub fn tracked(root: &Utf8Path, rev: &str) -> Result<BTreeSet<String>, RkError> {
Ok(entries(root, rev)?
.into_iter()
.map(|entry| entry.path)
.collect())
}
pub fn bash_set(root: &Utf8Path, rev: &str) -> Result<BTreeSet<String>, RkError> {
Ok(declared(
&tracked(root, rev)?,
&Declaration::at(root, rev)?,
&destinations(root, rev),
))
}
fn blobs(root: &Utf8Path, objects: &[&str]) -> Result<Vec<Vec<u8>>, RkError> {
let failed = |message: String| {
RkError::subprocess(
Diagnostic::new(Reason::SubprocessFailed, message)
.expected("git cat-file to read every packed blob"),
)
};
let mut command = std::process::Command::new(crate::probes::git_bin());
for var in crate::maintenance::GIT_HOOK_VARS {
command.env_remove(var);
}
let mut child = command
.arg("-C")
.arg(root.as_std_path())
.args(["cat-file", "--batch"])
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::null())
.spawn()
.map_err(|source| failed(format!("git did not run: {source}")))?;
let mut stdin = child
.stdin
.take()
.ok_or_else(|| failed("git cat-file took no input".to_owned()))?;
let mut input = String::new();
for object in objects {
input.push_str(object);
input.push('\n');
}
let writer = std::thread::spawn(move || stdin.write_all(input.as_bytes()));
let mut reader = std::io::BufReader::new(
child
.stdout
.take()
.ok_or_else(|| failed("git cat-file gave no output".to_owned()))?,
);
let mut out = Vec::with_capacity(objects.len());
for object in objects {
let mut header = String::new();
reader.read_line(&mut header).map_err(RkError::Io)?;
let size = header
.split(' ')
.nth(2)
.and_then(|size| size.trim().parse::<usize>().ok())
.ok_or_else(|| {
failed(format!(
"git cat-file did not read {object}: {}",
header.trim()
))
})?;
let mut bytes = vec![0; size];
reader.read_exact(&mut bytes).map_err(RkError::Io)?;
let mut newline = [0; 1];
reader.read_exact(&mut newline).map_err(RkError::Io)?;
out.push(bytes);
}
writer
.join()
.map_err(|_| failed("git cat-file's input stopped".to_owned()))?
.map_err(RkError::Io)?;
child.wait().map_err(RkError::Io)?;
Ok(out)
}
pub fn pack(
root: &Utf8Path,
rev: &str,
files: &BTreeSet<String>,
top: &str,
epoch: u64,
archive: &Utf8Path,
) -> Result<(), RkError> {
let shipped: Vec<Entry> = entries(root, rev)?
.into_iter()
.filter(|entry| files.contains(&entry.path))
.collect();
let objects: Vec<&str> = shipped.iter().map(|entry| entry.object.as_str()).collect();
let contents = blobs(root, &objects)?;
let file = std::fs::File::create(archive).map_err(RkError::Io)?;
let gzip = flate2::GzBuilder::new()
.mtime(0)
.write(file, flate2::Compression::default());
let mut tarball = tar::Builder::new(gzip);
for (entry, bytes) in shipped.iter().zip(contents) {
let path = format!("{top}/{}", entry.path);
let mut header = tar::Header::new_gnu();
header.set_uid(0);
header.set_gid(0);
header.set_username("").map_err(RkError::Io)?;
header.set_groupname("").map_err(RkError::Io)?;
header.set_mtime(epoch);
if entry.mode == "120000" {
header.set_entry_type(tar::EntryType::Symlink);
header.set_mode(0o777);
header.set_size(0);
let target = String::from_utf8_lossy(&bytes).into_owned();
tarball
.append_link(&mut header, &path, &target)
.map_err(RkError::Io)?;
} else {
header.set_entry_type(tar::EntryType::Regular);
header.set_mode(if entry.mode == "100755" { 0o755 } else { 0o644 });
header.set_size(bytes.len() as u64);
tarball
.append_data(&mut header, &path, bytes.as_slice())
.map_err(RkError::Io)?;
}
}
tarball
.into_inner()
.map_err(RkError::Io)?
.finish()
.map_err(RkError::Io)?;
Ok(())
}
pub fn unpack(archive: &Utf8Path, dir: &Utf8Path) -> Result<(), RkError> {
let file = std::fs::File::open(archive).map_err(RkError::Io)?;
tar::Archive::new(flate2::read::GzDecoder::new(file))
.unpack(dir)
.map_err(RkError::Io)
}
#[must_use]
pub fn destinations(root: &Utf8Path, rev: &str) -> BTreeSet<String> {
let spec = format!("{rev}:{RECORD_DIR}manifest.json");
let Ok(output) = super::git(root, &["show", &spec]) else {
return BTreeSet::new();
};
if !output.status.success() {
return BTreeSet::new();
}
serde_json::from_slice::<serde_json::Value>(&output.stdout)
.ok()
.and_then(|record| record.get("files").cloned())
.and_then(|files| files.as_array().cloned())
.unwrap_or_default()
.iter()
.filter_map(|file| file.get("destination")?.as_str().map(str::to_owned))
.collect()
}
pub fn epoch(root: &Utf8Path, rev: &str) -> Result<String, RkError> {
super::git_line(
root,
&["log", "-1", "--format=%ct", rev],
"the revision's commit time",
)
}
#[must_use]
pub fn mirrors_nothing(what: &str, rev: &str) -> RkError {
RkError::refusal(
Diagnostic::new(
Reason::PrerequisiteUnmet,
format!(
"{what} at {rev} holds no file git tracks there, so the request cannot tell which files the release packs"
),
)
.expected("a package whose members, below one top directory, are tracked files")
.action("configure the build backend to pack the project's tracked files, then read the sdist with tar -tzf")
.target_state("unchanged"),
)
}
pub struct TempDir {
dir: Utf8PathBuf,
}
impl TempDir {
pub fn new(purpose: &str) -> Result<Self, RkError> {
static COUNT: AtomicU64 = AtomicU64::new(0);
let base = Utf8PathBuf::from_path_buf(std::env::temp_dir()).map_err(|path| {
RkError::refusal(
Diagnostic::new(
Reason::PrerequisiteUnmet,
format!("the temporary directory {} is not UTF-8", path.display()),
)
.expected("a UTF-8 temporary directory")
.target_state("unchanged"),
)
})?;
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |elapsed| elapsed.subsec_nanos());
let dir = base.join(format!(
"rk-{purpose}-{}-{nanos}-{}",
std::process::id(),
COUNT.fetch_add(1, Ordering::Relaxed)
));
std::fs::create_dir_all(&dir).map_err(RkError::Io)?;
Ok(Self { dir })
}
#[must_use]
pub fn path(&self) -> &Utf8Path {
&self.dir
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.dir);
}
}
pub struct Scratch {
root: Utf8PathBuf,
dir: TempDir,
}
impl Scratch {
pub fn new(root: &Utf8Path, rev: &str) -> Result<Self, RkError> {
let scratch = Self {
root: root.to_owned(),
dir: TempDir::new("packed")?,
};
let tree = scratch.tree();
super::git_ok(
root,
&[
"-c",
"core.hooksPath=/dev/null",
"worktree",
"add",
"--detach",
"-q",
tree.as_str(),
rev,
],
"a temporary worktree of the revision",
)?;
Ok(scratch)
}
#[must_use]
pub fn tree(&self) -> Utf8PathBuf {
self.dir.path().join("tree")
}
#[must_use]
pub fn out(&self) -> Utf8PathBuf {
self.dir.path().join("out")
}
}
impl Drop for Scratch {
fn drop(&mut self) {
let tree = self.tree();
let _ = super::git(
&self.root,
&["worktree", "remove", "--force", tree.as_str()],
);
let _ = std::fs::remove_dir_all(&tree);
let _ = super::git(&self.root, &["worktree", "prune"]);
}
}
fn side(context: &Context, rev: &str) -> Result<BTreeSet<String>, RkError> {
let listed = context.adapter.packed(&context.target, rev)?;
Ok(subtract(listed, &destinations(&context.target, rev)))
}
pub fn compare(context: &Context, tag: &str) -> Result<Comparison, RkError> {
let root = &context.target;
let at = format!("refs/tags/{tag}^{{commit}}");
let held = super::git(root, &["rev-parse", "--verify", "-q", &at])?;
if !held.status.success() {
return Err(RkError::refusal(
Diagnostic::new(
Reason::ObservationIncomplete,
format!("the release tag {tag} is not in the checkout"),
)
.expected("a checkout that holds every release tag, as fetch-depth 0 fetches them")
.target_state("unchanged"),
));
}
let at_head = side(context, "HEAD")?;
let released = side(context, tag)?;
let moved = at_head != released;
let declared = moved && recorded(root, "HEAD") != recorded(root, tag);
let mut list = at_head;
list.extend(released);
if list.is_empty() {
return Ok(Comparison {
tag: tag.to_owned(),
paths: Vec::new(),
changed: false,
history: Vec::new(),
inputs: Vec::new(),
});
}
let paths: Vec<String> = list.into_iter().collect();
let changed = moved || differs(root, tag, &paths)?;
let mut history = paths.clone();
let mut inputs = paths.clone();
if let Some((file, _)) = super::adapter::version_file(context.adapter.driver())
&& !history.iter().any(|path| path == file)
{
history.push(file.to_owned());
}
if declared {
history.push(format!("{RECORD_DIR}config.toml"));
inputs.push(format!("{RECORD_DIR}config.toml"));
}
Ok(Comparison {
tag: tag.to_owned(),
paths,
changed,
history,
inputs,
})
}
pub fn differs(root: &Utf8Path, tag: &str, paths: &[String]) -> Result<bool, RkError> {
let mut args = vec![
"--literal-pathspecs",
"diff",
"--quiet",
"--no-ext-diff",
"--no-textconv",
tag,
"HEAD",
"--",
];
args.extend(paths.iter().map(String::as_str));
let output = super::git(root, &args)?;
match output.status.code() {
Some(0) => Ok(false),
Some(1) => Ok(true),
_ => Err(RkError::subprocess(
Diagnostic::new(Reason::SubprocessFailed, super::last_line(&output.stderr))
.expected("git to compare the packed files"),
)),
}
}
pub fn at_head(context: &Context) -> Result<Vec<String>, RkError> {
Ok(side(context, "HEAD")?.into_iter().collect())
}
pub fn stable_tag_below(root: &Utf8Path, exclude: Option<&str>) -> Result<Option<String>, RkError> {
let mut args = vec![
"describe",
"--tags",
"--abbrev=0",
"--match",
"v[0-9]*",
"--exclude",
"*-*",
];
if let Some(tag) = exclude {
args.extend(["--exclude", tag]);
}
args.push("HEAD");
let output = super::git(root, &args)?;
Ok(output
.status
.success()
.then(|| String::from_utf8_lossy(&output.stdout).trim().to_owned())
.filter(|found| !found.is_empty()))
}
pub fn previous_tag(root: &Utf8Path, tag: &str) -> Result<Option<String>, RkError> {
let output = super::git(
root,
&[
"describe",
"--tags",
"--abbrev=0",
"--match",
"v[0-9]*",
"--exclude",
tag,
"HEAD",
],
)?;
Ok(output
.status
.success()
.then(|| String::from_utf8_lossy(&output.stdout).trim().to_owned())
.filter(|found| !found.is_empty()))
}
#[cfg(test)]
mod tests {
use std::collections::BTreeSet;
use super::{cliff_pattern, drop_generated, keep_tracked, strip_top, subtract};
fn owned(paths: &[&str]) -> Vec<String> {
paths.iter().map(|path| (*path).to_owned()).collect()
}
fn set(paths: &[&str]) -> BTreeSet<String> {
owned(paths).into_iter().collect()
}
#[test]
fn the_packed_list_arithmetic_holds() {
assert_eq!(
strip_top(&owned(&[
"acme-0.1.0/",
"acme-0.1.0/src/a.py",
"acme-0.1.0/PKG-INFO"
])),
owned(&["src/a.py", "PKG-INFO"])
);
assert_eq!(
strip_top(&owned(&["./widget/README.md", "./widget/bin/tool"])),
owned(&["README.md", "bin/tool"])
);
assert_eq!(
strip_top(&owned(&["README.md", "src/tool.sh"])),
owned(&["README.md", "src/tool.sh"]),
"members with no shared top directory stay whole"
);
assert_eq!(strip_top(&owned(&["a/x", "b/y"])), owned(&["a/x", "b/y"]));
assert_eq!(
drop_generated(owned(&[
"PKG-INFO",
"pyproject.toml.orig",
"pyproject.toml",
"src/PKG-INFO"
])),
owned(&["pyproject.toml", "src/PKG-INFO"])
);
let tracked = set(&["pyproject.toml", "src/a.py", "Makefile"]);
assert_eq!(
keep_tracked(
owned(&["pyproject.toml", "src/a.py", "build/out.bin"]),
&tracked
),
set(&["pyproject.toml", "src/a.py"])
);
let head = subtract(
set(&[
".gitlab-ci.yml",
".release-kit/config.toml",
"src/a.py",
"cliff.toml",
]),
&set(&[".gitlab-ci.yml", "cliff.toml"]),
);
let at_tag = subtract(
set(&[".gitlab-ci.yml", "old.py", "cliff.toml"]),
&set(&["cliff.toml"]),
);
let mut union = head;
union.extend(at_tag);
assert_eq!(
union,
set(&[".gitlab-ci.yml", "old.py", "src/a.py"]),
"a destination the tag's record does not name counts from the tag's side"
);
assert_eq!(cliff_pattern("src/a[1]*.py"), "src/a[[]1[]][*].py");
assert_eq!(cliff_pattern("src/tool.sh"), "src/tool.sh");
}
fn selected(
include: &[&str],
exclude: &[&str],
tracked: &[&str],
recorded: &[&str],
) -> BTreeSet<String> {
let declaration =
super::Declaration::new(&owned(include), &owned(exclude)).expect("the patterns parse");
super::declared(&set(tracked), &declaration, &set(recorded))
}
#[test]
fn the_declaration_follows_cargo_semantics() {
let tree = [
"README.md",
"VERSION",
"Makefile",
"docs/README.md",
"docs/guide.md",
"src/tool.sh",
"src/lib/util.sh",
"src/lib/test_util.sh",
"tests/check.sh",
"nested/tests/data.txt",
"SECURITY.md",
".gitlab-ci.yml",
".release-kit/config.toml",
".release-kit/manifest.json",
];
let recorded = [".gitlab-ci.yml", "SECURITY.md", "VERSION"];
let all = |paths: &[&str]| set(paths);
assert_eq!(
selected(&[], &[], &tree, &recorded),
all(&[
"README.md",
"VERSION",
"Makefile",
"docs/README.md",
"docs/guide.md",
"src/tool.sh",
"src/lib/util.sh",
"src/lib/test_util.sh",
"tests/check.sh",
"nested/tests/data.txt",
])
);
let any_depth = selected(&[], &["README.md"], &tree, &recorded);
assert!(!any_depth.contains("README.md") && !any_depth.contains("docs/README.md"));
let anchored = selected(&[], &["/README.md"], &tree, &recorded);
assert!(!anchored.contains("README.md") && anchored.contains("docs/README.md"));
let dirs = selected(&[], &["tests/"], &tree, &recorded);
assert!(!dirs.contains("tests/check.sh") && !dirs.contains("nested/tests/data.txt"));
let root_dir = selected(&[], &["/tests/"], &tree, &recorded);
assert!(!root_dir.contains("tests/check.sh") && root_dir.contains("nested/tests/data.txt"));
let deep = selected(&["src/**/*.sh"], &[], &tree, &recorded);
assert_eq!(
deep,
all(&[
"VERSION",
"src/tool.sh",
"src/lib/util.sh",
"src/lib/test_util.sh"
])
);
let shallow = selected(&["/src/*.sh"], &[], &tree, &recorded);
assert_eq!(shallow, all(&["VERSION", "src/tool.sh"]));
assert_eq!(
selected(&["src/", "!test_*.sh"], &[], &tree, &recorded),
all(&["VERSION", "src/tool.sh", "src/lib/util.sh"])
);
assert_eq!(
selected(&["src/"], &["src/"], &tree, &recorded),
all(&[
"VERSION",
"src/tool.sh",
"src/lib/util.sh",
"src/lib/test_util.sh"
])
);
assert!(!selected(&["*.txt"], &[], &tree, &recorded).contains("notes.txt"));
assert!(selected(&[], &["VERSION"], &tree, &recorded).contains("VERSION"));
assert!(selected(&["src/"], &[], &tree, &recorded).contains("VERSION"));
assert!(!selected(&[], &[], &tree, &recorded).contains("SECURITY.md"));
assert!(!selected(&["*.md"], &[], &tree, &recorded).contains(".release-kit/config.toml"));
let named = selected(&["/SECURITY.md", "src/"], &[], &tree, &recorded);
assert!(named.contains("SECURITY.md") && !named.contains(".gitlab-ci.yml"));
assert!(
selected(&[".release-kit/"], &[], &tree, &recorded)
.contains(".release-kit/config.toml")
);
assert_eq!(
selected(&["/lib/"], &[], &tree, &recorded),
all(&["VERSION"])
);
}
}