diff --git a/Cargo.lock b/Cargo.lock index 0be5c42..ff40b90 100755 --- a/Cargo.lock +++ b/Cargo.lock @@ -418,7 +418,7 @@ checksum = "807800ff3288b621186fe0a8f3392c4652068257302709c24efd918c3dffcdc2" [[package]] name = "depot" -version = "0.27.1" +version = "0.28.0" dependencies = [ "anyhow", "ar", diff --git a/Cargo.toml b/Cargo.toml index 8d0983a..df89611 100755 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "depot" -version = "0.27.1" +version = "0.28.0" edition = "2024" [lints.rust] diff --git a/meson.build b/meson.build index adf4e82..4cd5e68 100644 --- a/meson.build +++ b/meson.build @@ -1,6 +1,6 @@ project( 'depot', - version: '0.27.1', + version: '0.28.0', meson_version: '>=0.60.0', default_options: ['buildtype=release'], ) diff --git a/src/commands.rs b/src/commands.rs index d660cc5..a89d710 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -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> { - 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> { 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, pending_staging_dirs: &mut Vec, - installed_outcomes: &mut Vec, 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( diff --git a/src/db/mod.rs b/src/db/mod.rs index 9266567..1899b20 100755 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -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 Result> { + 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 = 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> { 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")); + } } diff --git a/src/deps.rs b/src/deps.rs index cf93180..dcb9144 100755 --- a/src/deps.rs +++ b/src/deps.rs @@ -130,12 +130,16 @@ fn is_dep_satisfied( dep: &str, installed: &std::collections::HashSet, provides: &std::collections::HashSet, + replaces: &std::collections::HashSet, db_path: &Path, ) -> Result { 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 { 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, 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( diff --git a/src/planner.rs b/src/planner.rs index fee3059..1aa42f2 100644 --- a/src/planner.rs +++ b/src/planner.rs @@ -391,8 +391,9 @@ impl<'a> Resolver<'a> { } fn choose_candidate(&self, dep: &str, candidates: &[Candidate]) -> Result { - 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) -> Vec { out } +fn prune_replacement_fallback_candidates(candidates: Vec) -> Vec { + 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(); diff --git a/src/source/git.rs b/src/source/git.rs index 3075892..fcbed45 100755 --- a/src/source/git.rs +++ b/src/source/git.rs @@ -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> { + 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 { + 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 { + 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, @@ -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))); + } }