diff --git a/CHANGELOG.md b/CHANGELOG.md index fc874c2..ccf8ab5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to `eiipm` are documented here. This changelog follows the [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) format, and this project adheres to [Semantic Versioning](https://semver.org/). +## [0.4.0] - [UNRELEASED] + +### Added + +- Added commit hash based install/update for security reasons. + ## [0.3.0] - 2025-08-22 ### Added diff --git a/Cargo.lock b/Cargo.lock index 2093079..46f3d59 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -249,7 +249,7 @@ dependencies = [ [[package]] name = "eiipm" -version = "0.3.0" +version = "0.4.0" dependencies = [ "anyhow", "clap", diff --git a/Cargo.toml b/Cargo.toml index f1b7405..d2b2a36 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "eiipm" -version = "0.3.0" +version = "0.4.0" description = "Eiipm - A simple package manager for ewwii" authors = ["Byson94 "] license = "Apache-2.0" diff --git a/deprecated/git.mod.bak.rs b/deprecated/git.mod.bak.rs new file mode 100644 index 0000000..bba1c77 --- /dev/null +++ b/deprecated/git.mod.bak.rs @@ -0,0 +1,182 @@ +//! Working with git2 API's + +use crate::other::confirm_action::confirm; +use git2::{Cred, Error, FetchOptions, RemoteCallbacks, Repository, build::RepoBuilder}; +use std::fs; +use std::path::Path; + +pub fn clone_https(repo_url: &str, path: &Path, depth: Option) -> Result { + let callbacks = RemoteCallbacks::new(); + + // Set up fetch options + let mut fetch_options = FetchOptions::new(); + fetch_options.remote_callbacks(callbacks); + + // Apply shallow clone if depth is specified + if let Some(d) = depth { + fetch_options.depth(d as i32); + } + + // Use RepoBuilder directly + let mut builder = RepoBuilder::new(); + builder.fetch_options(fetch_options); + + let repo = builder.clone(repo_url, path)?; + Ok(repo) +} + +pub fn pull_https(repo: &Repository) -> Result<(), Error> { + let head_ref = repo.head()?; + let branch_name = head_ref + .shorthand() + .ok_or_else(|| Error::from_str("Invalid branch"))?; + + // Fetch remote branch + let callbacks = RemoteCallbacks::new(); + let mut fetch_options = FetchOptions::new(); + fetch_options.remote_callbacks(callbacks); + + let mut remote = repo.find_remote("origin")?; + remote.fetch(&[branch_name], Some(&mut fetch_options), None)?; + + // Find fetched commit + let fetch_ref = repo.find_reference(&format!("refs/remotes/origin/{}", branch_name))?; + let fetch_commit = repo.reference_to_annotated_commit(&fetch_ref)?; + + // Merge analysis + let analysis = repo.merge_analysis(&[&fetch_commit])?; + + if analysis.0.is_fast_forward() { + // Fast-forward + let mut ref_to_update = repo.find_reference(head_ref.name().unwrap())?; + ref_to_update.set_target(fetch_commit.id(), "Fast-forward")?; + repo.set_head(head_ref.name().unwrap())?; + repo.checkout_head(Some(git2::build::CheckoutBuilder::default().force()))?; + log::info!("Fast-forward merge completed"); + } else { + // Real merge + log::info!("Fast-forward not possible, performing merge..."); + + let head_commit = repo.reference_to_annotated_commit(&head_ref)?; + let head_tree = repo.find_commit(head_commit.id())?.tree()?; + let fetch_tree = repo.find_commit(fetch_commit.id())?.tree()?; + + let ancestor_commit = repo + .merge_base(head_commit.id(), fetch_commit.id()) + .and_then(|oid| repo.find_commit(oid))?; + let ancestor_tree = ancestor_commit.tree()?; + + let mut idx = repo.merge_trees(&ancestor_tree, &head_tree, &fetch_tree, None)?; + if idx.has_conflicts() { + return Err(Error::from_str( + "Merge conflicts detected. Please resolve manually.", + )); + } + + // Write the merged tree + let result_tree_id = idx.write_tree_to(repo)?; + let result_tree = repo.find_tree(result_tree_id)?; + + // Create merge commit + let sig = repo.signature()?; + let head_commit_obj = repo.find_commit(head_commit.id())?; + let fetch_commit_obj = repo.find_commit(fetch_commit.id())?; + + repo.commit( + Some(head_ref.name().unwrap()), // update current branch + &sig, + &sig, + "Merge commit from pull", + &result_tree, + &[&head_commit_obj, &fetch_commit_obj], + )?; + + // Checkout updated HEAD + repo.checkout_head(Some(git2::build::CheckoutBuilder::default().force()))?; + log::info!("Merge completed successfully"); + } + + Ok(()) +} + +pub fn pull_but_reclone_on_fail( + repo_url: &str, + repo_path: &Path, + depth: Option, +) -> Result { + // Try opening the repo if it exists + if let Ok(repo) = Repository::open(repo_path) { + // Try to pull + match pull_https(&repo) { + Ok(_) => return Ok(repo), + Err(err) => { + log::warn!("Pull failed: {}.", err); + + let user_confirm = confirm("Failed to update cache (outdated). Remove and retry?"); + + let home_dir = dirs::home_dir() + .ok_or_else(|| Error::from_str("Failed to get home directory"))?; + let cache_root = home_dir.join(".eiipm/cache"); + + if user_confirm { + if !repo_path.starts_with(cache_root.as_path()) { + return Err(Error::from_str(&format!( + "Refusing to delete outside cache: {}", + repo_path.display() + ))); + } + + fs::remove_dir_all(repo_path) + .map_err(|e| Error::from_str(&format!("Failed to remove dir: {}", e)))?; + } else { + // user refused, so just return the repo as-is + return Ok(repo); + } + } + } + } + + // Either repo didn't exist or we removed it, so clone fresh + clone_https(repo_url, repo_path, depth) +} + +/// Checks if the current branch is behind its upstream. +/// Returns `Ok(true)` if the upstream has commits the local branch doesn't have. +pub fn is_upstream_ahead(repo_path: &str) -> Result { + let repo = Repository::open(repo_path)?; + + // Get the current branch + let head_ref = repo.head()?; + let branch_name = head_ref + .shorthand() + .ok_or_else(|| Error::from_str("Invalid branch name"))?; + + // Set up fetch options with authentication callbacks + let mut callbacks = RemoteCallbacks::new(); + callbacks.credentials(|_url, username_from_url, _allowed_types| { + Cred::ssh_key_from_agent(username_from_url.unwrap_or("git")) + }); + let mut fetch_options = FetchOptions::new(); + fetch_options.remote_callbacks(callbacks); + + // Fetch from origin + let mut remote = repo.find_remote("origin")?; + remote.fetch(&[branch_name], Some(&mut fetch_options), None)?; + + // Resolve upstream + let local_branch = repo.find_branch(branch_name, git2::BranchType::Local)?; + let upstream_branch = local_branch.upstream()?; + + let local_oid = local_branch + .get() + .target() + .ok_or_else(|| Error::from_str("Local branch has no commit"))?; + let upstream_oid = upstream_branch + .get() + .target() + .ok_or_else(|| Error::from_str("Upstream branch has no commit"))?; + + let (_ahead, behind) = repo.graph_ahead_behind(local_oid, upstream_oid)?; + + Ok(behind > 0) +} diff --git a/docs/src/introduction.md b/docs/src/introduction.md index 5b87d75..fb46476 100644 --- a/docs/src/introduction.md +++ b/docs/src/introduction.md @@ -49,3 +49,7 @@ I use zsh, so I added the line `export PATH="$HOME/.eiipm/bin:$PATH"` in `~/.zsh For example, if you use bash, add that line in `~/.bashrc`. > **NOTE:** If you dont want to use echo to add it, then you can manually edit your configuration file and add the line `export PATH="$HOME/.eiipm/bin:$PATH"` in there. + +## Security Notice + +Third-party packages may contain vulnerabilities. Always verify that you trust the author, even if the package is officially approved and included in [eii-manifests](https://github.com/Ewwii-sh/eii-manifests). diff --git a/src/functions/checkupdate.rs b/src/functions/checkupdate.rs index 6804e34..71ebf4c 100644 --- a/src/functions/checkupdate.rs +++ b/src/functions/checkupdate.rs @@ -1,5 +1,4 @@ -use super::load_db; -use crate::git::is_upstream_ahead; +use super::{is_update_needed_for, load_db}; use colored::Colorize; use log::info; use std::error::Error; @@ -9,11 +8,11 @@ pub fn check_package_updates(package_name: &Option) -> Result<(), Box = Vec::new(); if let Some(name) = package_name { - if let Some(pkg) = db.packages.get_mut(name) { + if db.packages.get_mut(name).is_some() { info!("> Checking for '{}' update", name.yellow().bold()); - let need_update = is_upstream_ahead(&pkg.repo_path)?; + let need_update = is_update_needed_for(&name)?; - if need_update { + if need_update.0 { pkg_needing_update.push(name); } } else { @@ -21,11 +20,11 @@ pub fn check_package_updates(package_name: &Option) -> Result<(), Box Checking for updates in all packages..."); - for (name, pkg) in db.packages.iter_mut() { + for (name, ..) in db.packages.iter_mut() { info!("Checking '{}'", name.yellow().bold()); - let need_update = is_upstream_ahead(&pkg.repo_path)?; + let need_update = is_update_needed_for(&name)?; - if need_update { + if need_update.0 { pkg_needing_update.push(name); } } diff --git a/src/functions/clearcache.rs b/src/functions/clearcache.rs index 60a4be2..1d88c1f 100644 --- a/src/functions/clearcache.rs +++ b/src/functions/clearcache.rs @@ -28,7 +28,7 @@ pub fn clean_package_cache(package_name: Option) -> Result<(), Box Result<(), Box> { - let repo_path = PathBuf::from(&pkg.repo_path); + let repo_path = PathBuf::from(&pkg.repo_fs_path); let home_dir = dirs::home_dir().ok_or("Failed to get home directory")?; let cache_root = home_dir.join(".eiipm/cache"); diff --git a/src/functions/install.rs b/src/functions/install.rs index 27f9681..34af6a2 100644 --- a/src/functions/install.rs +++ b/src/functions/install.rs @@ -1,30 +1,14 @@ -use super::{FileEntry, InstalledPackage, http_get_string, load_db, save_db}; +use super::{FileEntry, InstalledPackage, PackageRootMeta, http_get_string, load_db, save_db}; use colored::Colorize; use dirs; use glob::glob; use log::{info, trace}; -use serde::Deserialize; use std::env; use std::error::Error; use std::fs; use std::process::Command; -use crate::git::{clone_https, pull_but_reclone_on_fail}; - -#[derive(Deserialize, Debug)] -struct PackageRootMeta { - metadata: PackageMeta, -} - -#[derive(Deserialize, Debug)] -struct PackageMeta { - name: String, - #[serde(rename = "type")] - pkg_type: String, - src: String, - files: Vec, - build: Option, // Optional build command -} +use crate::git::{init_and_fetch, update_to_latest}; pub fn install_package(package_name: &str) -> Result<(), Box> { info!("> Installing package '{}'", package_name.yellow().bold()); @@ -50,21 +34,21 @@ pub fn install_package(package_name: &str) -> Result<(), Box> { .strip_suffix(".git") .unwrap_or_else(|| meta.src.rsplit('/').next().unwrap()); - let repo_path = eiipm_dir.join(format!("cache/{}", repo_name)); + let repo_fs_path = eiipm_dir.join(format!("cache/{}", repo_name)); - // Clone or pull repo - if !repo_path.exists() { + // Init and fetch or fetch and clean repo + if !repo_fs_path.exists() { info!( "Cloning repository {} to {}", meta.src.underline(), - repo_path.display() + repo_fs_path.display() ); - let _repo = clone_https(&meta.src, &repo_path, Some(1)) - .map_err(|e| format!("Git clone failed: {}", e))?; + let _repo = init_and_fetch(&meta.src, &repo_fs_path, &meta.commit_hash, 1) + .map_err(|e| format!("Failed to fetch commit: {}", e))?; } else { - info!("Repository exists, pulling latest changes"); - pull_but_reclone_on_fail(&meta.src, &repo_path, Some(1)) - .map_err(|e| format!("Git pull failed: {}", e))?; + info!("Repository exists, fetching latest changes"); + let _repo = update_to_latest(&repo_fs_path, &meta.commit_hash, 1) + .map_err(|e| format!("Failed to fetch commit and clean state: {}", e))?; } // Optional build step @@ -73,7 +57,7 @@ pub fn install_package(package_name: &str) -> Result<(), Box> { let status = Command::new("sh") .arg("-c") .arg(build_cmd) - .current_dir(&repo_path) + .current_dir(&repo_fs_path) .status()?; if !status.success() { return Err(format!("Build failed for package '{}'", package_name).into()); @@ -94,7 +78,7 @@ pub fn install_package(package_name: &str) -> Result<(), Box> { for file_entry in &meta.files { // handle *, ** etc. in file entry let files: Vec<(std::path::PathBuf, std::path::PathBuf)> = match file_entry { - FileEntry::Flat(f) => glob(&repo_path.join(f).to_string_lossy()) + FileEntry::Flat(f) => glob(&repo_fs_path.join(f).to_string_lossy()) .expect("Invalid glob") .filter_map(Result::ok) .map(|src| { @@ -103,7 +87,7 @@ pub fn install_package(package_name: &str) -> Result<(), Box> { }) .collect(), - FileEntry::Detailed { src, dest } => glob(&repo_path.join(src).to_string_lossy()) + FileEntry::Detailed { src, dest } => glob(&repo_fs_path.join(src).to_string_lossy()) .expect("Invalid glob") .filter_map(Result::ok) .map(|src_path| { @@ -134,11 +118,13 @@ pub fn install_package(package_name: &str) -> Result<(), Box> { db.packages.insert( meta.name.clone(), InstalledPackage { - repo_path: repo_path.to_string_lossy().to_string(), + repo_fs_path: repo_fs_path.to_string_lossy().to_string(), installed_files: installed_files, copy_files: meta.files.clone(), pkg_type: meta.pkg_type.clone(), upstream_src: meta.src.clone(), + installed_hash: meta.commit_hash.clone(), + manifest_url: raw_manifest_url, build_command: meta.build.clone(), }, ); diff --git a/src/functions/list.rs b/src/functions/list.rs index 77c769d..8de5cf0 100644 --- a/src/functions/list.rs +++ b/src/functions/list.rs @@ -19,7 +19,7 @@ pub fn list_packages(list_args: ListArgs) -> Result<(), Box> { "{}\n Type: {}\n Repo: {}\n Build: {}\n Files:\n {}", pkg, package.pkg_type, - package.repo_path, + package.repo_fs_path, package .build_command .clone() @@ -42,7 +42,7 @@ pub fn list_packages(list_args: ListArgs) -> Result<(), Box> { "{}\n Type: {}\n Repo: {}\n Build: {}\n Files:\n {}", name, package.pkg_type, - package.repo_path, + package.repo_fs_path, package .build_command .clone() diff --git a/src/functions/mod.rs b/src/functions/mod.rs index df79181..5bb7304 100644 --- a/src/functions/mod.rs +++ b/src/functions/mod.rs @@ -22,19 +22,38 @@ pub struct PackageDB { packages: HashMap, } +/// Metadata structs +#[derive(Deserialize, Debug)] +pub struct PackageRootMeta { + metadata: PackageMeta, +} + +#[derive(Deserialize, Debug)] +pub struct PackageMeta { + name: String, + #[serde(rename = "type")] + pkg_type: String, + src: String, + #[serde(rename = "commit")] + commit_hash: String, // hash of the commit to install + files: Vec, + build: Option, // Optional build command +} + // Wait there dev! // if you add a new value to InstalledPackage, eiipm will break // no... no... eiipm wont break, but old db's that use the old // struct will break... So, remember to add `#[serde(default)]`. // #[serde(default)] is our lord and savior if we need to add a new value. - #[derive(Deserialize, Serialize, Debug)] pub struct InstalledPackage { - repo_path: String, // path to cached repo. E.g. ~/.eiipm/cache/ + repo_fs_path: String, // path to cached repo. E.g. ~/.eiipm/cache/ installed_files: Vec, copy_files: Vec, pkg_type: String, upstream_src: String, + installed_hash: String, + manifest_url: String, build_command: Option, } @@ -79,3 +98,20 @@ pub fn http_get_string(url: &str) -> Result> { } Ok(response.text()?) } + +pub fn is_update_needed_for(package_name: &str) -> Result<(bool, String), Box> { + let mut db = load_db()?; + + if let Some(pkg) = db.packages.get_mut(package_name) { + let upstream_manifest_raw = http_get_string(&pkg.manifest_url)?; + let root_manifest: PackageRootMeta = toml::from_str(&upstream_manifest_raw)?; + let upstream_manifest = root_manifest.metadata; + + Ok(( + upstream_manifest.commit_hash != pkg.installed_hash, + upstream_manifest.commit_hash, + )) + } else { + Err(format!("Package `{}` not found in DB", package_name).into()) + } +} diff --git a/src/functions/purgecache.rs b/src/functions/purgecache.rs index 7dc2858..d23a003 100644 --- a/src/functions/purgecache.rs +++ b/src/functions/purgecache.rs @@ -19,7 +19,7 @@ pub fn purge_cache() -> Result<(), Box> { .packages .values() .map(|pkg| { - Path::new(&pkg.repo_path) + Path::new(&pkg.repo_fs_path) .file_name() .unwrap() .to_string_lossy() diff --git a/src/functions/update.rs b/src/functions/update.rs index 8420107..0b53951 100644 --- a/src/functions/update.rs +++ b/src/functions/update.rs @@ -6,9 +6,9 @@ use std::fs; use std::path::PathBuf; use std::process::Command; -use super::{FileEntry, InstalledPackage, load_db, save_db}; +use super::{FileEntry, InstalledPackage, is_update_needed_for, load_db, save_db}; -use crate::git::{clone_https, is_upstream_ahead, pull_but_reclone_on_fail}; +use crate::git::{init_and_fetch, update_to_latest}; pub fn update_package(package_name: &Option) -> Result<(), Box> { let mut db = load_db()?; @@ -24,10 +24,10 @@ pub fn update_package(package_name: &Option) -> Result<(), Box Updating '{}'", name.yellow().bold()); - update_file(pkg, &name)?; + update_file(pkg, &name, need_update.1)?; info!("Successfully updated '{}'", name.yellow().bold()); } else { info!("Package '{}' is already up-to-date", name.yellow().bold()); @@ -50,10 +50,10 @@ pub fn update_package(package_name: &Option) -> Result<(), Box Updating '{}'", name.yellow().bold()); - update_file(pkg, &name)?; + update_file(pkg, &name, need_update.1)?; info!("Successfully updated '{}'", name.yellow().bold()); } else { info!("Package '{}' is already up-to-date", name.yellow().bold()); @@ -65,24 +65,29 @@ pub fn update_package(package_name: &Option) -> Result<(), Box Result<(), Box> { - let repo_path = PathBuf::from(&pkg.repo_path); +fn update_file( + pkg: &mut InstalledPackage, + package_name: &str, + commit_hash: String, +) -> Result<(), Box> { + let repo_fs_path = PathBuf::from(&pkg.repo_fs_path); // Clone/Pull latest changes debug!("Pulling latest version of {} using git...", package_name); - if !repo_path.exists() { + // Init and fetch or fetch and clean repo + if !repo_fs_path.exists() { info!( - "Cache not found. Cloning repository {} to {}", + "Cloning repository {} to {}", pkg.upstream_src.underline(), - repo_path.display() + repo_fs_path.display() ); - let _repo = clone_https(&pkg.upstream_src, &repo_path, Some(1)) - .map_err(|e| format!("Git clone failed: {}", e))?; + let _repo = init_and_fetch(&pkg.upstream_src, &repo_fs_path, &commit_hash, 1) + .map_err(|e| format!("Failed to fetch commit: {}", e))?; } else { - info!("Repository is cached, pulling latest changes"); - pull_but_reclone_on_fail(&pkg.upstream_src, &repo_path, Some(1)) - .map_err(|e| format!("Git pull failed: {}", e))?; + info!("Repository exists, fetching latest changes"); + let _repo = update_to_latest(&repo_fs_path, &commit_hash, 1) + .map_err(|e| format!("Failed to fetch commit and clean state: {}", e))?; } // Optional build step @@ -91,10 +96,10 @@ fn update_file(pkg: &mut InstalledPackage, package_name: &str) -> Result<(), Box let status = Command::new("sh") .arg("-c") .arg(build_cmd) - .current_dir(&repo_path) + .current_dir(&repo_fs_path) .status()?; if !status.success() { - return Err(format!("Build failed for package '{}'", pkg.repo_path).into()); + return Err(format!("Build failed for package '{}'", pkg.repo_fs_path).into()); } } @@ -118,7 +123,7 @@ fn update_file(pkg: &mut InstalledPackage, package_name: &str) -> Result<(), Box for file_entry in &pkg.copy_files { // handle *, **, etc. in file entry let files: Vec<(std::path::PathBuf, std::path::PathBuf)> = match file_entry { - FileEntry::Flat(f) => glob(&repo_path.join(f).to_string_lossy()) + FileEntry::Flat(f) => glob(&repo_fs_path.join(f).to_string_lossy()) .expect("Invalid glob") .filter_map(Result::ok) .map(|src| { @@ -127,7 +132,7 @@ fn update_file(pkg: &mut InstalledPackage, package_name: &str) -> Result<(), Box }) .collect(), - FileEntry::Detailed { src, dest } => glob(&repo_path.join(src).to_string_lossy()) + FileEntry::Detailed { src, dest } => glob(&repo_fs_path.join(src).to_string_lossy()) .expect("Invalid glob") .filter_map(Result::ok) .map(|src_path| { @@ -154,5 +159,7 @@ fn update_file(pkg: &mut InstalledPackage, package_name: &str) -> Result<(), Box } } + pkg.installed_hash = commit_hash; + Ok(()) } diff --git a/src/git/mod.rs b/src/git/mod.rs index bba1c77..c8993d6 100644 --- a/src/git/mod.rs +++ b/src/git/mod.rs @@ -1,182 +1,81 @@ -//! Working with git2 API's +//! Minimal fetch & checkout with git2 -use crate::other::confirm_action::confirm; -use git2::{Cred, Error, FetchOptions, RemoteCallbacks, Repository, build::RepoBuilder}; -use std::fs; +use git2::{Error, FetchOptions, RemoteCallbacks, Repository}; use std::path::Path; -pub fn clone_https(repo_url: &str, path: &Path, depth: Option) -> Result { +/// Initialize a repo at `path` and fetch a specific commit from origin. +/// +/// Equivalent to: +/// ```bash +/// git init +/// git fetch --depth 1 origin +/// git checkout FETCH_HEAD +/// ``` +pub fn init_and_fetch( + repo_url: &str, + path: &Path, + commit: &str, + fetch_depth: i32, +) -> Result { + // initialize new git repository + let repo = Repository::init(path)?; + + repo.remote("origin", repo_url)?; + + // prepare fetch options (shallow, depth=1) let callbacks = RemoteCallbacks::new(); + let mut fetch_opts = FetchOptions::new(); + fetch_opts.remote_callbacks(callbacks); + fetch_opts.depth(fetch_depth); - // Set up fetch options - let mut fetch_options = FetchOptions::new(); - fetch_options.remote_callbacks(callbacks); - - // Apply shallow clone if depth is specified - if let Some(d) = depth { - fetch_options.depth(d as i32); + // Fetch the given commit + { + let mut remote = repo.find_remote("origin")?; + remote.fetch(&[commit], Some(&mut fetch_opts), None)?; } - // Use RepoBuilder directly - let mut builder = RepoBuilder::new(); - builder.fetch_options(fetch_options); + { + // Point HEAD to FETCH_HEAD + let fetch_head = repo.find_reference("FETCH_HEAD")?; + let commit = repo.reference_to_annotated_commit(&fetch_head)?; + + repo.set_head_detached(commit.id())?; + repo.checkout_head(Some(git2::build::CheckoutBuilder::default().force()))?; + } - let repo = builder.clone(repo_url, path)?; Ok(repo) } -pub fn pull_https(repo: &Repository) -> Result<(), Error> { - let head_ref = repo.head()?; - let branch_name = head_ref - .shorthand() - .ok_or_else(|| Error::from_str("Invalid branch"))?; +/// Fetch the latest commit from `origin/`, checkout it, +/// and discard all previous history (like a shallow reset). +pub fn update_to_latest(repo_path: &Path, commit: &str, fetch_depth: i32) -> Result<(), Error> { + let repo = Repository::open(repo_path)?; - // Fetch remote branch + // Prepare fetch options (shallow) let callbacks = RemoteCallbacks::new(); - let mut fetch_options = FetchOptions::new(); - fetch_options.remote_callbacks(callbacks); + let mut fetch_opts = FetchOptions::new(); + fetch_opts.remote_callbacks(callbacks); + fetch_opts.depth(fetch_depth); - let mut remote = repo.find_remote("origin")?; - remote.fetch(&[branch_name], Some(&mut fetch_options), None)?; - - // Find fetched commit - let fetch_ref = repo.find_reference(&format!("refs/remotes/origin/{}", branch_name))?; - let fetch_commit = repo.reference_to_annotated_commit(&fetch_ref)?; - - // Merge analysis - let analysis = repo.merge_analysis(&[&fetch_commit])?; - - if analysis.0.is_fast_forward() { - // Fast-forward - let mut ref_to_update = repo.find_reference(head_ref.name().unwrap())?; - ref_to_update.set_target(fetch_commit.id(), "Fast-forward")?; - repo.set_head(head_ref.name().unwrap())?; - repo.checkout_head(Some(git2::build::CheckoutBuilder::default().force()))?; - log::info!("Fast-forward merge completed"); - } else { - // Real merge - log::info!("Fast-forward not possible, performing merge..."); - - let head_commit = repo.reference_to_annotated_commit(&head_ref)?; - let head_tree = repo.find_commit(head_commit.id())?.tree()?; - let fetch_tree = repo.find_commit(fetch_commit.id())?.tree()?; - - let ancestor_commit = repo - .merge_base(head_commit.id(), fetch_commit.id()) - .and_then(|oid| repo.find_commit(oid))?; - let ancestor_tree = ancestor_commit.tree()?; - - let mut idx = repo.merge_trees(&ancestor_tree, &head_tree, &fetch_tree, None)?; - if idx.has_conflicts() { - return Err(Error::from_str( - "Merge conflicts detected. Please resolve manually.", - )); - } - - // Write the merged tree - let result_tree_id = idx.write_tree_to(repo)?; - let result_tree = repo.find_tree(result_tree_id)?; - - // Create merge commit - let sig = repo.signature()?; - let head_commit_obj = repo.find_commit(head_commit.id())?; - let fetch_commit_obj = repo.find_commit(fetch_commit.id())?; - - repo.commit( - Some(head_ref.name().unwrap()), // update current branch - &sig, - &sig, - "Merge commit from pull", - &result_tree, - &[&head_commit_obj, &fetch_commit_obj], - )?; - - // Checkout updated HEAD - repo.checkout_head(Some(git2::build::CheckoutBuilder::default().force()))?; - log::info!("Merge completed successfully"); + // Fetch from origin + { + let mut remote = repo.find_remote("origin")?; + remote.fetch(&[commit], Some(&mut fetch_opts), None)?; } + // Point HEAD to FETCH_HEAD + let fetch_head = repo.find_reference("FETCH_HEAD")?; + let commit = repo.reference_to_annotated_commit(&fetch_head)?; + let commit_obj = repo.find_commit(commit.id())?; + + // Reset hard to that commit + repo.reset( + commit_obj.as_object(), + git2::ResetType::Hard, + Some(git2::build::CheckoutBuilder::default().force()), + )?; + + let _ = repo.cleanup_state(); + Ok(()) } - -pub fn pull_but_reclone_on_fail( - repo_url: &str, - repo_path: &Path, - depth: Option, -) -> Result { - // Try opening the repo if it exists - if let Ok(repo) = Repository::open(repo_path) { - // Try to pull - match pull_https(&repo) { - Ok(_) => return Ok(repo), - Err(err) => { - log::warn!("Pull failed: {}.", err); - - let user_confirm = confirm("Failed to update cache (outdated). Remove and retry?"); - - let home_dir = dirs::home_dir() - .ok_or_else(|| Error::from_str("Failed to get home directory"))?; - let cache_root = home_dir.join(".eiipm/cache"); - - if user_confirm { - if !repo_path.starts_with(cache_root.as_path()) { - return Err(Error::from_str(&format!( - "Refusing to delete outside cache: {}", - repo_path.display() - ))); - } - - fs::remove_dir_all(repo_path) - .map_err(|e| Error::from_str(&format!("Failed to remove dir: {}", e)))?; - } else { - // user refused, so just return the repo as-is - return Ok(repo); - } - } - } - } - - // Either repo didn't exist or we removed it, so clone fresh - clone_https(repo_url, repo_path, depth) -} - -/// Checks if the current branch is behind its upstream. -/// Returns `Ok(true)` if the upstream has commits the local branch doesn't have. -pub fn is_upstream_ahead(repo_path: &str) -> Result { - let repo = Repository::open(repo_path)?; - - // Get the current branch - let head_ref = repo.head()?; - let branch_name = head_ref - .shorthand() - .ok_or_else(|| Error::from_str("Invalid branch name"))?; - - // Set up fetch options with authentication callbacks - let mut callbacks = RemoteCallbacks::new(); - callbacks.credentials(|_url, username_from_url, _allowed_types| { - Cred::ssh_key_from_agent(username_from_url.unwrap_or("git")) - }); - let mut fetch_options = FetchOptions::new(); - fetch_options.remote_callbacks(callbacks); - - // Fetch from origin - let mut remote = repo.find_remote("origin")?; - remote.fetch(&[branch_name], Some(&mut fetch_options), None)?; - - // Resolve upstream - let local_branch = repo.find_branch(branch_name, git2::BranchType::Local)?; - let upstream_branch = local_branch.upstream()?; - - let local_oid = local_branch - .get() - .target() - .ok_or_else(|| Error::from_str("Local branch has no commit"))?; - let upstream_oid = upstream_branch - .get() - .target() - .ok_or_else(|| Error::from_str("Upstream branch has no commit"))?; - - let (_ahead, behind) = repo.graph_ahead_behind(local_oid, upstream_oid)?; - - Ok(behind > 0) -}