feat: update depot version to 0.28.0 and implement package replacement handling in database
This commit is contained in:
Generated
+1
-1
@@ -418,7 +418,7 @@ checksum = "807800ff3288b621186fe0a8f3392c4652068257302709c24efd918c3dffcdc2"
|
||||
|
||||
[[package]]
|
||||
name = "depot"
|
||||
version = "0.27.1"
|
||||
version = "0.28.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ar",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "depot"
|
||||
version = "0.27.1"
|
||||
version = "0.28.0"
|
||||
edition = "2024"
|
||||
|
||||
[lints.rust]
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
project(
|
||||
'depot',
|
||||
version: '0.27.1',
|
||||
version: '0.28.0',
|
||||
meson_version: '>=0.60.0',
|
||||
default_options: ['buildtype=release'],
|
||||
)
|
||||
|
||||
+15
-54
@@ -1276,6 +1276,7 @@ struct PlannedPackageInstall {
|
||||
staged: PlannedStagedInstall,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[derive(Debug, Clone)]
|
||||
struct InstalledPackageOutcome {
|
||||
package: package::PackageInfo,
|
||||
@@ -1870,8 +1871,7 @@ fn install_planned_packages_to_rootfs(
|
||||
plans: &[PlannedPackageInstall],
|
||||
rootfs: &Path,
|
||||
config: &config::Config,
|
||||
) -> Result<Vec<InstalledPackageOutcome>> {
|
||||
let mut installed = Vec::with_capacity(plans.len());
|
||||
) -> Result<()> {
|
||||
let mut removed_replacements = HashSet::new();
|
||||
for (idx, plan) in plans.iter().enumerate() {
|
||||
ui::info(format!(
|
||||
@@ -1884,30 +1884,13 @@ fn install_planned_packages_to_rootfs(
|
||||
));
|
||||
for package in &plan.staged.replacement_removals {
|
||||
if removed_replacements.insert(package.clone()) {
|
||||
ui::info(format!("Removing package {}...", package));
|
||||
remove_installed_package_with_hooks(package, rootfs, config)?;
|
||||
}
|
||||
}
|
||||
install_staged_to_rootfs(&plan.spec, &plan.destdir, rootfs, config, &plan.staged)?;
|
||||
installed.push(InstalledPackageOutcome {
|
||||
package: plan.spec.package.clone(),
|
||||
is_update: plan.staged.is_update,
|
||||
});
|
||||
}
|
||||
install::scripts::run_deferred_hooks_if_possible(rootfs)?;
|
||||
Ok(installed)
|
||||
}
|
||||
|
||||
fn log_install_success(outcome: &InstalledPackageOutcome) {
|
||||
let action = install_success_action(outcome.is_update);
|
||||
ui::success(format!(
|
||||
"Successfully {} {} v{}",
|
||||
action, outcome.package.name, outcome.package.version
|
||||
));
|
||||
}
|
||||
|
||||
fn install_success_action(is_update: bool) -> &'static str {
|
||||
if is_update { "updated" } else { "installed" }
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -1918,8 +1901,15 @@ fn install_package_outputs_to_rootfs(
|
||||
config: &config::Config,
|
||||
) -> Result<Vec<InstalledPackageOutcome>> {
|
||||
let plans = plan_package_outputs_for_install(pkg_spec, destdir, rootfs, config)?;
|
||||
let installed = plans
|
||||
.iter()
|
||||
.map(|plan| InstalledPackageOutcome {
|
||||
package: plan.spec.package.clone(),
|
||||
is_update: plan.staged.is_update,
|
||||
})
|
||||
.collect();
|
||||
run_transaction_hooks_for_plans(rootfs, install::hooks::HookPhase::Pre, &plans)?;
|
||||
let installed = install_planned_packages_to_rootfs(&plans, rootfs, config)?;
|
||||
install_planned_packages_to_rootfs(&plans, rootfs, config)?;
|
||||
run_transaction_hooks_for_plans(rootfs, install::hooks::HookPhase::Post, &plans)?;
|
||||
Ok(installed)
|
||||
}
|
||||
@@ -3727,7 +3717,6 @@ fn build_live_rootfs_child_install_batches(
|
||||
fn flush_binary_install_batch(
|
||||
pending_plans: &mut Vec<PlannedPackageInstall>,
|
||||
pending_staging_dirs: &mut Vec<tempfile::TempDir>,
|
||||
installed_outcomes: &mut Vec<InstalledPackageOutcome>,
|
||||
rootfs: &Path,
|
||||
config: &config::Config,
|
||||
) -> Result<()> {
|
||||
@@ -3735,8 +3724,7 @@ fn flush_binary_install_batch(
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let installed = install_planned_packages_to_rootfs(pending_plans, rootfs, config)?;
|
||||
installed_outcomes.extend(installed);
|
||||
install_planned_packages_to_rootfs(pending_plans, rootfs, config)?;
|
||||
pending_plans.clear();
|
||||
pending_staging_dirs.clear();
|
||||
Ok(())
|
||||
@@ -4014,14 +4002,12 @@ fn execute_install_plan_with_child_commands(
|
||||
let mut binary_post_hook_plans = Vec::new();
|
||||
let mut pending_binary_install_plans = Vec::new();
|
||||
let mut pending_binary_install_staging_dirs = Vec::new();
|
||||
let mut installed_binary_outcomes = Vec::new();
|
||||
for (idx, step) in actionable_steps.into_iter().enumerate() {
|
||||
match &step.origin {
|
||||
planner::PlanOrigin::Source { path, .. } => {
|
||||
flush_binary_install_batch(
|
||||
&mut pending_binary_install_plans,
|
||||
&mut pending_binary_install_staging_dirs,
|
||||
&mut installed_binary_outcomes,
|
||||
rootfs,
|
||||
config,
|
||||
)?;
|
||||
@@ -4075,14 +4061,9 @@ fn execute_install_plan_with_child_commands(
|
||||
flush_binary_install_batch(
|
||||
&mut pending_binary_install_plans,
|
||||
&mut pending_binary_install_staging_dirs,
|
||||
&mut installed_binary_outcomes,
|
||||
rootfs,
|
||||
config,
|
||||
)?;
|
||||
for pkg in installed_binary_outcomes {
|
||||
log_install_success(&pkg);
|
||||
}
|
||||
|
||||
run_transaction_hooks_for_plans(
|
||||
rootfs,
|
||||
install::hooks::HookPhase::Post,
|
||||
@@ -4208,10 +4189,7 @@ fn run_direct_archive_install_requests(
|
||||
install::hooks::HookPhase::Pre,
|
||||
&transaction_plans,
|
||||
)?;
|
||||
let installed = install_planned_packages_to_rootfs(&transaction_plans, options.rootfs, config)?;
|
||||
for pkg in installed {
|
||||
log_install_success(&pkg);
|
||||
}
|
||||
install_planned_packages_to_rootfs(&transaction_plans, options.rootfs, config)?;
|
||||
run_transaction_hooks_for_plans(
|
||||
options.rootfs,
|
||||
install::hooks::HookPhase::Post,
|
||||
@@ -4637,10 +4615,7 @@ fn run_direct_install_request(
|
||||
)?;
|
||||
let _snapper_post_install_snapshot_todo: fn() -> ! =
|
||||
|| todo!("snapper: create post-install snapshot after install commit succeeds");
|
||||
let installed = install_planned_packages_to_rootfs(&transaction_plans, options.rootfs, config)?;
|
||||
for pkg in installed {
|
||||
log_install_success(&pkg);
|
||||
}
|
||||
install_planned_packages_to_rootfs(&transaction_plans, options.rootfs, config)?;
|
||||
run_transaction_hooks_for_plans(
|
||||
options.rootfs,
|
||||
install::hooks::HookPhase::Post,
|
||||
@@ -4808,7 +4783,6 @@ pub fn run(cli: Cli) -> Result<()> {
|
||||
..
|
||||
}) => {
|
||||
let rootfs = rootfs_args.rootfs;
|
||||
ui::info(format!("Removing package: {}", package));
|
||||
let config = config::Config::for_rootfs(&rootfs);
|
||||
let mut remove_lock = locking::open_lock(&config)?;
|
||||
let remove_lock_path = locking::lock_path(&config);
|
||||
@@ -5281,14 +5255,7 @@ pub fn run(cli: Cli) -> Result<()> {
|
||||
install::hooks::HookPhase::Pre,
|
||||
&transaction_plans,
|
||||
)?;
|
||||
let installed = install_planned_packages_to_rootfs(
|
||||
&transaction_plans,
|
||||
&rootfs,
|
||||
&config,
|
||||
)?;
|
||||
for pkg in installed {
|
||||
log_install_success(&pkg);
|
||||
}
|
||||
install_planned_packages_to_rootfs(&transaction_plans, &rootfs, &config)?;
|
||||
run_transaction_hooks_for_plans(
|
||||
&rootfs,
|
||||
install::hooks::HookPhase::Post,
|
||||
@@ -7988,12 +7955,6 @@ optional = []
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn install_success_action_uses_updated_for_replacements() {
|
||||
assert_eq!(install_success_action(false), "installed");
|
||||
assert_eq!(install_success_action(true), "updated");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_type_runs_automatic_tests_matches_builder_behavior() {
|
||||
assert!(build_type_runs_automatic_tests(&test_package_spec(
|
||||
|
||||
+64
-8
@@ -165,6 +165,10 @@ pub fn register_package_with_replacement(
|
||||
"DELETE FROM provides WHERE package_id = ?1",
|
||||
params![pkg_id],
|
||||
)?;
|
||||
tx.execute(
|
||||
"DELETE FROM replaces WHERE package_id = ?1",
|
||||
params![pkg_id],
|
||||
)?;
|
||||
tx.execute("DELETE FROM files WHERE package_id = ?1", params![pkg_id])?;
|
||||
tx.execute(
|
||||
"DELETE FROM directories WHERE package_id = ?1",
|
||||
@@ -179,6 +183,13 @@ pub fn register_package_with_replacement(
|
||||
)?;
|
||||
}
|
||||
|
||||
for replaces in &spec.alternatives.replaces {
|
||||
tx.execute(
|
||||
"INSERT OR IGNORE INTO replaces (package_id, replaces_name) VALUES (?1, ?2)",
|
||||
params![pkg_id, replaces],
|
||||
)?;
|
||||
}
|
||||
|
||||
// Detect ownership conflicts and separate into auto-removable vs fatal.
|
||||
let mut fatal_conflicts: Vec<(String, String)> = Vec::new();
|
||||
let mut auto_conflicts: Vec<(String, String)> = Vec::new();
|
||||
@@ -375,7 +386,6 @@ pub fn remove_package(db_path: &Path, name: &str, rootfs: &Path) -> Result<()> {
|
||||
}
|
||||
|
||||
// Remove directories (only if empty and not owned by another package)
|
||||
let mut dirs_removed = 0;
|
||||
for dir in &directories {
|
||||
let path = rootfs.join(dir);
|
||||
|
||||
@@ -401,7 +411,6 @@ pub fn remove_package(db_path: &Path, name: &str, rootfs: &Path) -> Result<()> {
|
||||
if verbose_remove_output() {
|
||||
crate::log_info!(" Removed directory: {}", dir);
|
||||
}
|
||||
dirs_removed += 1;
|
||||
}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
|
||||
// Already gone
|
||||
@@ -421,12 +430,6 @@ pub fn remove_package(db_path: &Path, name: &str, rootfs: &Path) -> Result<()> {
|
||||
// Remove from database
|
||||
delete_package_rows(&conn, pkg_id)?;
|
||||
|
||||
crate::log_info!(
|
||||
"Removed {} files and {} directories",
|
||||
files.len(),
|
||||
dirs_removed
|
||||
);
|
||||
|
||||
if !removal_errors.is_empty() {
|
||||
crate::log_warn!("Failed to remove some paths:");
|
||||
for err in removal_errors {
|
||||
@@ -520,6 +523,14 @@ fn init_db(conn: &Connection) -> Result<()> {
|
||||
UNIQUE(package_id, provides_name)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS replaces (
|
||||
id INTEGER PRIMARY KEY,
|
||||
package_id INTEGER NOT NULL,
|
||||
replaces_name TEXT NOT NULL,
|
||||
FOREIGN KEY (package_id) REFERENCES packages(id),
|
||||
UNIQUE(package_id, replaces_name)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS files (
|
||||
id INTEGER PRIMARY KEY,
|
||||
package_id INTEGER NOT NULL,
|
||||
@@ -537,6 +548,7 @@ fn init_db(conn: &Connection) -> Result<()> {
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_files_package ON files(package_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_provides_name ON provides(provides_name);
|
||||
CREATE INDEX IF NOT EXISTS idx_replaces_name ON replaces(replaces_name);
|
||||
CREATE INDEX IF NOT EXISTS idx_directories_package ON directories(package_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_directories_path ON directories(path);
|
||||
",
|
||||
@@ -614,6 +626,10 @@ fn delete_package_rows(conn: &Connection, pkg_id: i64) -> Result<()> {
|
||||
"DELETE FROM provides WHERE package_id = ?1",
|
||||
params![pkg_id],
|
||||
)?;
|
||||
conn.execute(
|
||||
"DELETE FROM replaces WHERE package_id = ?1",
|
||||
params![pkg_id],
|
||||
)?;
|
||||
conn.execute("DELETE FROM packages WHERE id = ?1", params![pkg_id])?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -628,6 +644,10 @@ fn delete_package_rows_tx(tx: &rusqlite::Transaction<'_>, pkg_id: i64) -> Result
|
||||
"DELETE FROM provides WHERE package_id = ?1",
|
||||
params![pkg_id],
|
||||
)?;
|
||||
tx.execute(
|
||||
"DELETE FROM replaces WHERE package_id = ?1",
|
||||
params![pkg_id],
|
||||
)?;
|
||||
tx.execute("DELETE FROM packages WHERE id = ?1", params![pkg_id])?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -803,6 +823,24 @@ pub fn get_all_provides(db_path: &Path) -> Result<std::collections::HashSet<Stri
|
||||
Ok(names)
|
||||
}
|
||||
|
||||
/// Get set of all replacement names satisfied by installed packages.
|
||||
pub fn get_all_replaces(db_path: &Path) -> Result<std::collections::HashSet<String>> {
|
||||
use std::collections::HashSet;
|
||||
|
||||
if !db_path.exists() {
|
||||
return Ok(HashSet::new());
|
||||
}
|
||||
|
||||
let conn = Connection::open(db_path)?;
|
||||
init_db(&conn)?;
|
||||
let mut stmt = conn.prepare("SELECT replaces_name FROM replaces")?;
|
||||
let names: HashSet<String> = stmt
|
||||
.query_map([], |row| row.get(0))?
|
||||
.filter_map(|r| r.ok())
|
||||
.collect();
|
||||
Ok(names)
|
||||
}
|
||||
|
||||
/// Get version of a specific installed package
|
||||
pub fn get_package_version(db_path: &Path, name: &str) -> Result<Option<String>> {
|
||||
if !db_path.exists() {
|
||||
@@ -1179,4 +1217,22 @@ mod tests {
|
||||
assert!(files.contains(&"usr/bin/new_file".to_string()));
|
||||
assert!(!files.contains(&"usr/bin/shared_dir/old_file".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn register_package_persists_replacements() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let db_path = tmp.path().join("packages.db");
|
||||
let dest = tmp.path().join("dest");
|
||||
std::fs::create_dir_all(dest.join("usr/bin")).unwrap();
|
||||
std::fs::write(dest.join("usr/bin/vx"), "vx").unwrap();
|
||||
|
||||
let mut spec = mk_spec("vx", "1.0");
|
||||
spec.alternatives.replaces = vec!["grep".into(), "patch".into()];
|
||||
|
||||
register_package(&db_path, &spec, &dest).unwrap();
|
||||
|
||||
let replaces = get_all_replaces(&db_path).unwrap();
|
||||
assert!(replaces.contains("grep"));
|
||||
assert!(replaces.contains("patch"));
|
||||
}
|
||||
}
|
||||
|
||||
+34
-8
@@ -130,12 +130,16 @@ fn is_dep_satisfied(
|
||||
dep: &str,
|
||||
installed: &std::collections::HashSet<String>,
|
||||
provides: &std::collections::HashSet<String>,
|
||||
replaces: &std::collections::HashSet<String>,
|
||||
db_path: &Path,
|
||||
) -> Result<bool> {
|
||||
let parsed = parse_dep(dep);
|
||||
|
||||
// Check if package is installed or provided
|
||||
if !installed.contains(parsed.name) && !provides.contains(parsed.name) {
|
||||
// Check if package is installed, provided, or replaced by an installed package.
|
||||
if !installed.contains(parsed.name)
|
||||
&& !provides.contains(parsed.name)
|
||||
&& !replaces.contains(parsed.name)
|
||||
{
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
@@ -148,8 +152,8 @@ fn is_dep_satisfied(
|
||||
if let Some(installed_version) = db::get_package_version(db_path, parsed.name)? {
|
||||
Ok(compare_versions(&installed_version, required, parsed.op))
|
||||
} else {
|
||||
// Package might be provided by an alternative, accept it
|
||||
Ok(provides.contains(parsed.name))
|
||||
// Package might be satisfied by an alternative or replacement, accept it.
|
||||
Ok(provides.contains(parsed.name) || replaces.contains(parsed.name))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -172,7 +176,8 @@ pub fn is_dep_satisfied_in_db(dep: &str, db_path: &Path) -> Result<bool> {
|
||||
|
||||
let installed = db::get_installed_packages(db_path)?;
|
||||
let provides = db::get_all_provides(db_path)?;
|
||||
is_dep_satisfied(dep, &installed, &provides, db_path)
|
||||
let replaces = db::get_all_replaces(db_path)?;
|
||||
is_dep_satisfied(dep, &installed, &provides, &replaces, db_path)
|
||||
}
|
||||
|
||||
fn push_unique(v: &mut Vec<String>, item: String) {
|
||||
@@ -252,9 +257,10 @@ pub(crate) fn check_build_deps_for_outputs(
|
||||
|
||||
let installed = db::get_installed_packages(db_path)?;
|
||||
let provides = db::get_all_provides(db_path)?;
|
||||
let replaces = db::get_all_replaces(db_path)?;
|
||||
|
||||
for dep in &build_deps {
|
||||
if !is_dep_satisfied(dep, &installed, &provides, db_path)? {
|
||||
if !is_dep_satisfied(dep, &installed, &provides, &replaces, db_path)? {
|
||||
missing.push(dep.clone());
|
||||
}
|
||||
}
|
||||
@@ -283,12 +289,13 @@ pub(crate) fn check_runtime_deps_for_outputs(
|
||||
|
||||
let installed = db::get_installed_packages(db_path)?;
|
||||
let provides = db::get_all_provides(db_path)?;
|
||||
let replaces = db::get_all_replaces(db_path)?;
|
||||
|
||||
for dep in &runtime_deps {
|
||||
if local_provides.contains(dep_name(dep)) {
|
||||
continue;
|
||||
}
|
||||
if !is_dep_satisfied(dep, &installed, &provides, db_path)? {
|
||||
if !is_dep_satisfied(dep, &installed, &provides, &replaces, db_path)? {
|
||||
missing.push(dep.clone());
|
||||
}
|
||||
}
|
||||
@@ -311,9 +318,10 @@ pub(crate) fn check_test_deps_for_outputs(
|
||||
|
||||
let installed = db::get_installed_packages(db_path)?;
|
||||
let provides = db::get_all_provides(db_path)?;
|
||||
let replaces = db::get_all_replaces(db_path)?;
|
||||
|
||||
for dep in &test_deps {
|
||||
if !is_dep_satisfied(dep, &installed, &provides, db_path)? {
|
||||
if !is_dep_satisfied(dep, &installed, &provides, &replaces, db_path)? {
|
||||
missing.push(dep.clone());
|
||||
}
|
||||
}
|
||||
@@ -750,6 +758,24 @@ mod tests {
|
||||
assert_eq!(missing, vec!["foo".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_installed_replacements_satisfy_dependencies() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let db_path = tmp.path().join("packages.db");
|
||||
let destdir = tmp.path().join("dest");
|
||||
std::fs::create_dir_all(destdir.join("usr/bin")).unwrap();
|
||||
std::fs::write(destdir.join("usr/bin/vx"), "vx").unwrap();
|
||||
|
||||
let mut spec = test_spec_with_build(BuildType::Custom, None, &[]);
|
||||
spec.package.name = "vx".into();
|
||||
spec.alternatives.replaces = vec!["patch".into(), "grep".into()];
|
||||
|
||||
crate::db::register_package(&db_path, &spec, &destdir).unwrap();
|
||||
|
||||
assert!(is_dep_satisfied_in_db("patch", &db_path).unwrap());
|
||||
assert!(is_dep_satisfied_in_db("grep", &db_path).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_type_runs_automatic_tests_matches_builder_behavior() {
|
||||
assert!(build_type_runs_automatic_tests(&test_spec_with_build(
|
||||
|
||||
+53
-6
@@ -391,8 +391,9 @@ impl<'a> Resolver<'a> {
|
||||
}
|
||||
|
||||
fn choose_candidate(&self, dep: &str, candidates: &[Candidate]) -> Result<Candidate> {
|
||||
let mut sorted =
|
||||
dedupe_candidate_packages(sort_candidates(candidates, self.opts.prefer_binary));
|
||||
let mut sorted = prune_replacement_fallback_candidates(dedupe_candidate_packages(
|
||||
sort_candidates(candidates, self.opts.prefer_binary),
|
||||
));
|
||||
|
||||
if sorted.len() == 1 {
|
||||
return Ok(sorted.remove(0));
|
||||
@@ -785,6 +786,20 @@ fn dedupe_candidate_packages(candidates: Vec<Candidate>) -> Vec<Candidate> {
|
||||
out
|
||||
}
|
||||
|
||||
fn prune_replacement_fallback_candidates(candidates: Vec<Candidate>) -> Vec<Candidate> {
|
||||
if candidates
|
||||
.iter()
|
||||
.any(|candidate| candidate.match_kind != MatchKind::Replaces)
|
||||
{
|
||||
candidates
|
||||
.into_iter()
|
||||
.filter(|candidate| candidate.match_kind != MatchKind::Replaces)
|
||||
.collect()
|
||||
} else {
|
||||
candidates
|
||||
}
|
||||
}
|
||||
|
||||
fn candidate_sort_key(c: &Candidate, prefer_binary: bool) -> (i32, i32, i32, String, String) {
|
||||
let is_binary = matches!(c.kind, CandidateKind::Binary { .. });
|
||||
let kind_rank = match (prefer_binary, is_binary) {
|
||||
@@ -794,9 +809,9 @@ fn candidate_sort_key(c: &Candidate, prefer_binary: bool) -> (i32, i32, i32, Str
|
||||
(false, true) => 1,
|
||||
};
|
||||
let match_rank = match c.match_kind {
|
||||
MatchKind::Replaces => 0,
|
||||
MatchKind::Exact => 1,
|
||||
MatchKind::Provides => 2,
|
||||
MatchKind::Exact => 0,
|
||||
MatchKind::Provides => 1,
|
||||
MatchKind::Replaces => 2,
|
||||
};
|
||||
(
|
||||
kind_rank,
|
||||
@@ -838,7 +853,8 @@ pub(crate) fn build_dependency_install_plan(
|
||||
mod tests {
|
||||
use super::{
|
||||
Candidate, CandidateKind, MatchKind, PlannerOptions, build_dependency_install_plan,
|
||||
dedupe_candidate_packages, sort_candidates, source_deps_for_install,
|
||||
dedupe_candidate_packages, prune_replacement_fallback_candidates, sort_candidates,
|
||||
source_deps_for_install,
|
||||
};
|
||||
use crate::config::Config;
|
||||
use crate::db;
|
||||
@@ -1064,6 +1080,37 @@ mod tests {
|
||||
assert!(matches!(deduped[0].kind, CandidateKind::Source { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replacement_candidates_are_pruned_when_direct_matches_exist() {
|
||||
let mut replacement = mk_binary_candidate("vx", "core", 0);
|
||||
replacement.match_kind = MatchKind::Replaces;
|
||||
|
||||
let mut exact = mk_binary_candidate("patch", "core", 0);
|
||||
exact.match_kind = MatchKind::Exact;
|
||||
|
||||
let mut provides = mk_binary_candidate("busybox", "core", 0);
|
||||
provides.match_kind = MatchKind::Provides;
|
||||
|
||||
let pruned = prune_replacement_fallback_candidates(vec![replacement, exact, provides]);
|
||||
assert_eq!(pruned.len(), 2);
|
||||
assert!(
|
||||
pruned
|
||||
.iter()
|
||||
.all(|candidate| candidate.match_kind != MatchKind::Replaces)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replacement_candidates_remain_when_they_are_the_only_matches() {
|
||||
let mut replacement = mk_binary_candidate("vx", "core", 0);
|
||||
replacement.match_kind = MatchKind::Replaces;
|
||||
|
||||
let pruned = prune_replacement_fallback_candidates(vec![replacement]);
|
||||
assert_eq!(pruned.len(), 1);
|
||||
assert!(matches!(pruned[0].match_kind, MatchKind::Replaces));
|
||||
assert_eq!(pruned[0].package, "vx");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_dependency_install_plan_skips_installed_dependency() {
|
||||
let rootfs = tempfile::tempdir().unwrap();
|
||||
|
||||
+239
-35
@@ -1,7 +1,9 @@
|
||||
//! Git source support via libgit2 (git2 crate)
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use git2::{Cred, CredentialType, FetchOptions, Oid, RemoteCallbacks, Repository};
|
||||
use git2::{
|
||||
CheckoutNotificationType, Cred, CredentialType, FetchOptions, Oid, RemoteCallbacks, Repository,
|
||||
};
|
||||
use indicatif::{ProgressBar, ProgressDrawTarget, ProgressStyle};
|
||||
use inquire::Password;
|
||||
use sha2::{Digest, Sha256};
|
||||
@@ -23,6 +25,7 @@ pub fn checkout(
|
||||
pkgname: &str,
|
||||
cherry_pick_revs: &[String],
|
||||
) -> Result<()> {
|
||||
crate::interrupts::install().context("Failed to enable Ctrl-C handling for git operations")?;
|
||||
fs::create_dir_all(git_cache_dir).with_context(|| {
|
||||
format!(
|
||||
"Failed to create git cache dir: {}",
|
||||
@@ -31,7 +34,7 @@ pub fn checkout(
|
||||
})?;
|
||||
|
||||
let mirror_dir = git_cache_dir.join(mirror_key(url));
|
||||
ensure_mirror(url, &mirror_dir, pkgname)?;
|
||||
ensure_mirror(url, &mirror_dir, pkgname, rev)?;
|
||||
|
||||
if checkout_dir.exists() {
|
||||
fs::remove_dir_all(checkout_dir).with_context(|| {
|
||||
@@ -54,9 +57,15 @@ pub fn checkout(
|
||||
|
||||
let mut builder = git2::build::RepoBuilder::new();
|
||||
builder.with_checkout(checkout);
|
||||
builder
|
||||
.clone(mirror_url, checkout_dir)
|
||||
.with_context(|| format!("Failed to clone from mirror for {}", url))?;
|
||||
match builder.clone(mirror_url, checkout_dir) {
|
||||
Ok(_) => {}
|
||||
Err(_err) if crate::interrupts::was_interrupted() => {
|
||||
anyhow::bail!("Interrupted by Ctrl-C while cloning {}", url)
|
||||
}
|
||||
Err(err) => {
|
||||
return Err(err).with_context(|| format!("Failed to clone from mirror for {}", url));
|
||||
}
|
||||
}
|
||||
checkout_progress.finish("checkout complete");
|
||||
|
||||
let repo = Repository::open(checkout_dir)?;
|
||||
@@ -141,34 +150,83 @@ fn mirror_key(url: &str) -> String {
|
||||
format!("{:x}", digest)
|
||||
}
|
||||
|
||||
fn ensure_mirror(url: &str, mirror_dir: &Path, pkgname: &str) -> Result<()> {
|
||||
if !mirror_dir.exists() {
|
||||
crate::log_info!("Cloning git mirror for {} ({})...", pkgname, url);
|
||||
let mut fo = FetchOptions::new();
|
||||
let transfer_progress = TransferProgress::new(format!("git {}", pkgname));
|
||||
fo.remote_callbacks(authenticated_remote_callbacks(
|
||||
Some(transfer_progress.bar()),
|
||||
url,
|
||||
));
|
||||
let mut builder = git2::build::RepoBuilder::new();
|
||||
builder.fetch_options(fo);
|
||||
builder.bare(true);
|
||||
builder
|
||||
.clone(url, mirror_dir)
|
||||
.with_context(|| format!("Failed to clone git mirror: {}", url))?;
|
||||
transfer_progress.finish("mirror ready");
|
||||
fn ensure_mirror(url: &str, mirror_dir: &Path, pkgname: &str, rev: &str) -> Result<()> {
|
||||
let fresh = !mirror_dir.exists();
|
||||
let repo = if fresh {
|
||||
crate::log_info!("Initializing git mirror for {} ({})...", pkgname, url);
|
||||
Repository::init_bare(mirror_dir)
|
||||
.with_context(|| format!("Failed to initialize git mirror: {}", mirror_dir.display()))?
|
||||
} else {
|
||||
Repository::open_bare(mirror_dir)
|
||||
.with_context(|| format!("Failed to open git mirror: {}", mirror_dir.display()))?
|
||||
};
|
||||
|
||||
if should_skip_fetch_for_cached_rev(&repo, rev) {
|
||||
crate::log_info!("Using cached git revision '{}' for {}.", rev, pkgname);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Fetch updates
|
||||
let repo = Repository::open_bare(mirror_dir)
|
||||
.with_context(|| format!("Failed to open git mirror: {}", mirror_dir.display()))?;
|
||||
let mut remote = ensure_origin_remote(&repo, url)?;
|
||||
let fetch_attempts = fetch_attempts_for_rev(rev);
|
||||
let mut attempted = false;
|
||||
|
||||
let mut remote = repo
|
||||
.find_remote("origin")
|
||||
.or_else(|_| repo.remote_anonymous(url))
|
||||
.with_context(|| format!("Failed to create remote for {}", url))?;
|
||||
for attempt in &fetch_attempts {
|
||||
let Some(message) = fetch_attempt_message(attempt, rev, fresh) else {
|
||||
continue;
|
||||
};
|
||||
attempted = true;
|
||||
let refspecs = attempt.refspecs();
|
||||
fetch_remote_refspecs(&mut remote, url, pkgname, &refspecs)?;
|
||||
if resolve_rev_object(&repo, rev).is_ok() {
|
||||
crate::log_info!("{}", message);
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
if !attempted {
|
||||
anyhow::bail!("No fetch strategy available for git revision '{}'", rev);
|
||||
}
|
||||
|
||||
anyhow::bail!("Failed to fetch git revision '{}'", rev)
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
enum FetchAttempt {
|
||||
Default,
|
||||
HeadRefs,
|
||||
Tag(String),
|
||||
Branch(String),
|
||||
}
|
||||
|
||||
impl FetchAttempt {
|
||||
fn refspecs(&self) -> Vec<&str> {
|
||||
match self {
|
||||
FetchAttempt::Default => Vec::new(),
|
||||
FetchAttempt::HeadRefs => vec!["+refs/heads/*:refs/remotes/origin/*"],
|
||||
FetchAttempt::Tag(tag) => vec![tag.as_str()],
|
||||
FetchAttempt::Branch(branch) => vec![branch.as_str()],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn ensure_origin_remote<'a>(repo: &'a Repository, url: &str) -> Result<git2::Remote<'a>> {
|
||||
match repo.find_remote("origin") {
|
||||
Ok(remote) => Ok(remote),
|
||||
Err(_) => {
|
||||
repo.remote("origin", url)
|
||||
.with_context(|| format!("Failed to create remote for {}", url))?;
|
||||
repo.find_remote("origin")
|
||||
.with_context(|| format!("Failed to reopen remote for {}", url))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn fetch_remote_refspecs(
|
||||
remote: &mut git2::Remote<'_>,
|
||||
url: &str,
|
||||
pkgname: &str,
|
||||
refspecs: &[&str],
|
||||
) -> Result<()> {
|
||||
let mut fo = FetchOptions::new();
|
||||
let transfer_progress = TransferProgress::new(format!("git {}", pkgname));
|
||||
fo.remote_callbacks(authenticated_remote_callbacks(
|
||||
@@ -176,15 +234,78 @@ fn ensure_mirror(url: &str, mirror_dir: &Path, pkgname: &str) -> Result<()> {
|
||||
url,
|
||||
));
|
||||
|
||||
// Fetch all remote refs (tags + heads). Empty refspec uses default.
|
||||
remote
|
||||
.fetch(&[] as &[&str], Some(&mut fo), None)
|
||||
.with_context(|| format!("Failed to fetch updates for {}", url))?;
|
||||
transfer_progress.finish("mirror updated");
|
||||
|
||||
match remote.fetch(refspecs, Some(&mut fo), None) {
|
||||
Ok(_) => {}
|
||||
Err(_err) if crate::interrupts::was_interrupted() => {
|
||||
anyhow::bail!("Interrupted by Ctrl-C while fetching {}", url)
|
||||
}
|
||||
Err(err) => {
|
||||
return Err(err).with_context(|| format!("Failed to fetch updates for {}", url));
|
||||
}
|
||||
}
|
||||
transfer_progress.finish("git fetch complete");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn fetch_attempt_message(attempt: &FetchAttempt, rev: &str, fresh: bool) -> Option<String> {
|
||||
let state = if fresh {
|
||||
"mirror ready"
|
||||
} else {
|
||||
"mirror updated"
|
||||
};
|
||||
match attempt {
|
||||
FetchAttempt::Default => Some(state.to_string()),
|
||||
FetchAttempt::HeadRefs => Some(format!("{state} (heads only)")),
|
||||
FetchAttempt::Tag(_) => Some(format!("{state} (tag {})", rev)),
|
||||
FetchAttempt::Branch(_) => Some(format!("{state} (branch {})", rev)),
|
||||
}
|
||||
}
|
||||
|
||||
fn fetch_attempts_for_rev(rev: &str) -> Vec<FetchAttempt> {
|
||||
if rev.eq_ignore_ascii_case("HEAD") {
|
||||
return vec![FetchAttempt::HeadRefs, FetchAttempt::Default];
|
||||
}
|
||||
|
||||
if is_probably_oid(rev) {
|
||||
return vec![FetchAttempt::Default];
|
||||
}
|
||||
|
||||
vec![
|
||||
FetchAttempt::Tag(tag_refspec(rev)),
|
||||
FetchAttempt::Branch(branch_refspec(rev)),
|
||||
FetchAttempt::Default,
|
||||
]
|
||||
}
|
||||
|
||||
fn should_skip_fetch_for_cached_rev(repo: &Repository, rev: &str) -> bool {
|
||||
if rev.eq_ignore_ascii_case("HEAD") {
|
||||
return false;
|
||||
}
|
||||
|
||||
if repo.find_reference(&format!("refs/tags/{rev}")).is_ok() {
|
||||
return true;
|
||||
}
|
||||
|
||||
if is_probably_oid(rev) {
|
||||
return resolve_rev_object(repo, rev).is_ok();
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
fn is_probably_oid(rev: &str) -> bool {
|
||||
let len = rev.len();
|
||||
(7..=40).contains(&len) && rev.bytes().all(|b| b.is_ascii_hexdigit())
|
||||
}
|
||||
|
||||
fn tag_refspec(rev: &str) -> String {
|
||||
format!("refs/tags/{rev}:refs/tags/{rev}")
|
||||
}
|
||||
|
||||
fn branch_refspec(rev: &str) -> String {
|
||||
format!("+refs/heads/{rev}:refs/remotes/origin/{rev}")
|
||||
}
|
||||
|
||||
fn checkout_rev(repo: &Repository, rev: &str) -> Result<()> {
|
||||
let obj = resolve_rev_object(repo, rev)
|
||||
.with_context(|| format!("Could not resolve git rev: {}", rev))?;
|
||||
@@ -291,6 +412,10 @@ pub(crate) fn authenticated_remote_callbacks(
|
||||
credential_state.provide(url, username_from_url, allowed)
|
||||
});
|
||||
if let Some(progress_bar) = progress_bar {
|
||||
let sideband_bar = progress_bar.clone();
|
||||
callbacks
|
||||
.sideband_progress(move |_message| git_operation_should_continue(Some(&sideband_bar)));
|
||||
|
||||
callbacks.transfer_progress(move |stats| {
|
||||
let total_objects = stats.total_objects() as u64;
|
||||
if total_objects > 0 {
|
||||
@@ -304,7 +429,8 @@ pub(crate) fn authenticated_remote_callbacks(
|
||||
stats.indexed_deltas(),
|
||||
stats.received_bytes()
|
||||
));
|
||||
true
|
||||
|
||||
git_operation_should_continue(Some(&progress_bar))
|
||||
});
|
||||
}
|
||||
|
||||
@@ -360,6 +486,22 @@ impl CheckoutProgress {
|
||||
}
|
||||
|
||||
fn attach(&self, checkout: &mut git2::build::CheckoutBuilder<'static>) {
|
||||
checkout.notify_on(
|
||||
CheckoutNotificationType::CONFLICT
|
||||
| CheckoutNotificationType::DIRTY
|
||||
| CheckoutNotificationType::UPDATED
|
||||
| CheckoutNotificationType::UNTRACKED
|
||||
| CheckoutNotificationType::IGNORED,
|
||||
);
|
||||
|
||||
let notify_bar = self.bar.clone();
|
||||
checkout.notify(move |_, path, _, _, _| {
|
||||
if let Some(path) = path {
|
||||
notify_bar.set_message(path.display().to_string());
|
||||
}
|
||||
git_operation_should_continue(Some(¬ify_bar))
|
||||
});
|
||||
|
||||
let bar = self.bar.clone();
|
||||
checkout.progress(move |path, current, total| {
|
||||
let total = total as u64;
|
||||
@@ -387,6 +529,17 @@ fn progress_draw_target() -> ProgressDrawTarget {
|
||||
}
|
||||
}
|
||||
|
||||
fn git_operation_should_continue(progress_bar: Option<&ProgressBar>) -> bool {
|
||||
if !crate::interrupts::was_interrupted() {
|
||||
return true;
|
||||
}
|
||||
|
||||
if let Some(progress_bar) = progress_bar {
|
||||
progress_bar.finish_and_clear();
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct CredentialState {
|
||||
username: Option<String>,
|
||||
@@ -635,4 +788,55 @@ mod tests {
|
||||
.contains("Could not resolve cherry-pick rev")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fetch_attempts_for_head_prefers_heads_only_before_full_fetch() {
|
||||
let attempts = fetch_attempts_for_rev("HEAD");
|
||||
assert!(matches!(
|
||||
attempts.as_slice(),
|
||||
[FetchAttempt::HeadRefs, FetchAttempt::Default]
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fetch_attempts_for_named_revision_try_tag_then_branch_then_fallback() {
|
||||
let attempts = fetch_attempts_for_rev("v1.2.3");
|
||||
assert_eq!(attempts.len(), 3);
|
||||
assert!(
|
||||
matches!(&attempts[0], FetchAttempt::Tag(tag) if tag == "refs/tags/v1.2.3:refs/tags/v1.2.3")
|
||||
);
|
||||
assert!(
|
||||
matches!(&attempts[1], FetchAttempt::Branch(branch) if branch == "+refs/heads/v1.2.3:refs/remotes/origin/v1.2.3")
|
||||
);
|
||||
assert!(matches!(attempts[2], FetchAttempt::Default));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fetch_attempts_for_oid_use_full_fetch_only() {
|
||||
let attempts = fetch_attempts_for_rev("0123456789abcdef");
|
||||
assert!(matches!(attempts.as_slice(), [FetchAttempt::Default]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_skip_fetch_for_cached_tag_revisions() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let workdir = temp.path().join("repo");
|
||||
std::fs::create_dir_all(&workdir).unwrap();
|
||||
let repo = Repository::init(&workdir).unwrap();
|
||||
|
||||
let commit_oid = commit_file(&repo, &workdir, "README", "hello");
|
||||
let tag_target = repo.find_object(commit_oid, None).unwrap();
|
||||
repo.tag_lightweight("v1.0.0", &tag_target, false).unwrap();
|
||||
|
||||
assert!(should_skip_fetch_for_cached_rev(&repo, "v1.0.0"));
|
||||
assert!(!should_skip_fetch_for_cached_rev(&repo, "main"));
|
||||
assert!(!should_skip_fetch_for_cached_rev(&repo, "HEAD"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_operation_should_continue_allows_normal_progress() {
|
||||
let bar = ProgressBar::hidden();
|
||||
crate::interrupts::reset();
|
||||
assert!(git_operation_should_continue(Some(&bar)));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user