feat: update depot version to 0.28.0 and implement package replacement handling in database

This commit is contained in:
2026-03-20 21:31:42 -05:00
parent bdb34c5c88
commit 9c4f686eb4
8 changed files with 408 additions and 114 deletions
Generated
+1 -1
View File
@@ -418,7 +418,7 @@ checksum = "807800ff3288b621186fe0a8f3392c4652068257302709c24efd918c3dffcdc2"
[[package]]
name = "depot"
version = "0.27.1"
version = "0.28.0"
dependencies = [
"anyhow",
"ar",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "depot"
version = "0.27.1"
version = "0.28.0"
edition = "2024"
[lints.rust]
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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();
+238 -34
View File
@@ -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))
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(&notify_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)));
}
}