From c5bcf222f98d13b104231713bf4a0aa0833c420a Mon Sep 17 00:00:00 2001 From: nsfisis Date: Fri, 5 Jun 2026 00:50:47 +0900 Subject: feat(downloader): wire VcsDownloader subclasses via shared downloaders Share downloaders as Rc> in DownloadManager and make DownloaderInterface/VcsDownloader take &mut self, matching PHP's mutable-by-reference downloader objects. This lets Git/Svn/Hg/Fossil/Perforce implement VcsDownloader and route download/install/update/prepare/cleanup through the trait instead of todo!(), and wires the as_* downcast hooks end-to-end. ChangeReportInterface::get_local_changes becomes &mut self since FileDownloader downloads to a compare dir; get_commit_logs/reapply_changes gain &mut self / Result to match the concrete implementations. Co-Authored-By: Claude Opus 4.8 --- crates/shirabe/src/downloader/git_downloader.rs | 2360 ++++++++++++----------- 1 file changed, 1193 insertions(+), 1167 deletions(-) (limited to 'crates/shirabe/src/downloader/git_downloader.rs') diff --git a/crates/shirabe/src/downloader/git_downloader.rs b/crates/shirabe/src/downloader/git_downloader.rs index 34f89a4..26ff4aa 100644 --- a/crates/shirabe/src/downloader/git_downloader.rs +++ b/crates/shirabe/src/downloader/git_downloader.rs @@ -14,6 +14,7 @@ use crate::config::Config; use crate::downloader::ChangeReportInterface; use crate::downloader::DvcsDownloaderInterface; use crate::downloader::VcsCapableDownloaderInterface; +use crate::downloader::VcsDownloader; use crate::downloader::VcsDownloaderBase; use crate::io::IOInterface; use crate::io::IOInterfaceImmutable; @@ -60,619 +61,902 @@ impl GitDownloader { } } - pub(crate) async fn do_download( - &mut self, - package: PackageInterfaceHandle, - _path: &str, - url: &str, - _prev_package: Option, - ) -> Result> { - // Do not create an extra local cache when repository is already local - if Filesystem::is_local_path(url) { + pub fn get_unpushed_changes( + &self, + _package: PackageInterfaceHandle, + path: &str, + ) -> Result> { + GitUtil::clean_env(&self.inner.process); + let path = self.normalize_path(path); + if !self.has_metadata_repository(&path) { return Ok(None); } - GitUtil::clean_env(&self.inner.process); - - let cache_path = format!( - "{}/{}/", - self.inner - .config - .borrow_mut() - .get("cache-vcs-dir") - .as_string() - .unwrap_or(""), - Preg::replace(r"{[^a-z0-9.]}i", "-", &Url::sanitize(url.to_string()))?, - ); - let git_version = GitUtil::get_version(&self.inner.process); - - // --dissociate option is only available since git 2.3.0-rc0 - if git_version.is_some() - && version_compare(git_version.as_deref().unwrap_or(""), "2.3.0-rc0", ">=") - && Cache::is_usable(&cache_path) + let command = vec![ + "git".to_string(), + "show-ref".to_string(), + "--head".to_string(), + "-d".to_string(), + ]; + let mut output = String::new(); + if self + .inner + .process + .borrow_mut() + .execute_args(&command, &mut output, Some(path.clone())) + != 0 { - self.inner.io.write_error3( - &format!( - " - Syncing {} ({}) into cache", - package.get_name(), - package - .get_full_pretty_version(true, crate::package::DisplayMode::SourceRefIfDev), - ), - true, - io_interface::NORMAL, - ); - self.inner.io.write_error3( - &sprintf( - " Cloning to cache at %s", - &[PhpMixed::String(cache_path.clone())], - ), - true, - io_interface::DEBUG, - ); - let r#ref = package.get_source_reference(); - let pretty_version = package.get_pretty_version(); - if self.git_util.fetch_ref_or_sync_mirror( - url, - &cache_path, - r#ref.as_deref().unwrap_or(""), - Some(&pretty_version), - )? && is_dir(&cache_path) - { - self.cached_packages - .entry(package.get_id()) - .or_insert_with(IndexMap::new) - .insert(r#ref.as_deref().unwrap_or("").to_string(), true); - } - } else if git_version.is_none() { return Err(RuntimeException { - message: "git was not found in your PATH, skipping source download".to_string(), + message: format!( + "Failed to execute {}\n\n{}", + implode(" ", &command), + self.inner.process.borrow().get_error_output(), + ), code: 0, } .into()); } - Ok(None) - } + let mut refs = trim(&output, None); + let mut head_match: IndexMap = IndexMap::new(); + if !Preg::is_match_strict_groups3(r"{^([a-f0-9]+) HEAD$}mi", &refs, Some(&mut head_match)) + .unwrap_or(false) + { + // could not match the HEAD for some reason + return Ok(None); + } + let head_ref = head_match + .get(&CaptureKey::ByIndex(1)) + .cloned() + .unwrap_or_default(); - pub(crate) async fn do_install( - &mut self, - package: PackageInterfaceHandle, - path: &str, - url: &str, - ) -> Result> { - GitUtil::clean_env(&self.inner.process); - let path = self.normalize_path(path); - let cache_path = format!( - "{}/{}/", - self.inner - .config - .borrow_mut() - .get("cache-vcs-dir") - .as_string() - .unwrap_or(""), - Preg::replace(r"{[^a-z0-9.]}i", "-", &Url::sanitize(url.to_string()))?, - ); - let r#ref = package.get_source_reference().unwrap_or_default(); + let mut branches_match: IndexMap> = IndexMap::new(); + if !Preg::is_match_all_strict_groups3( + &format!("{{^{} refs/heads/(.+)$}}mi", preg_quote(&head_ref, None)), + &refs, + Some(&mut branches_match), + ) + .unwrap_or(false) + { + // not on a branch, we are either on a not-modified tag or some sort of detached head, so skip this + return Ok(None); + } + let candidate_branches: Vec = branches_match + .get(&CaptureKey::ByIndex(1)) + .cloned() + .unwrap_or_default(); - let msg; - let commands: Vec>; - let has_cached = self - .cached_packages - .get(&package.get_id()) - .and_then(|m| m.get(&r#ref)) - .copied() - .unwrap_or(false); - if has_cached { - msg = format!("Cloning {} from cache", self.get_short_hash(&r#ref)); + // use the first match as branch name for now + let mut branch = candidate_branches[0].clone(); + let mut unpushed_changes: Option = None; + let mut branch_not_found_error = false; - let mut clone_flags: Vec = vec![ - "--dissociate".to_string(), - "--reference".to_string(), - cache_path.clone(), - ]; - let transport_options = package.get_transport_options(); - if let Some(git_opts) = transport_options.get("git").and_then(|v| v.as_array()) { - if let Some(single) = git_opts.get("single_use_clone").and_then(|v| v.as_bool()) { - if single { - clone_flags = vec![]; + // do two passes, as if we find anything we want to fetch and then re-try + for i in 0..=1 { + let mut remote_branches: Vec = vec![]; + + // try to find matching branch names in remote repos + for candidate in &candidate_branches { + let mut m: IndexMap> = IndexMap::new(); + if Preg::is_match_all_strict_groups3( + &format!( + "{{^[a-f0-9]+ refs/remotes/((?:[^/]+)/{})$}}mi", + preg_quote(candidate, None) + ), + &refs, + Some(&mut m), + ) + .unwrap_or(false) + { + let matches: Vec = + m.get(&CaptureKey::ByIndex(1)).cloned().unwrap_or_default(); + for match_ in matches { + branch = candidate.clone(); + remote_branches.push(match_); } + break; } } - commands = vec![ - { - let mut base = vec![ + // if it doesn't exist, then we assume it is an unpushed branch + // this is bad as we have no reference point to do a diff so we just bail listing + // the branch as being unpushed + if remote_branches.is_empty() { + unpushed_changes = Some(format!( + "Branch {} could not be found on any remote and appears to be unpushed", + branch + )); + branch_not_found_error = true; + } else { + // if first iteration found no remote branch but it has now found some, reset $unpushedChanges + // so we get the real diff output no matter its length + if branch_not_found_error { + unpushed_changes = None; + } + for remote_branch in &remote_branches { + let command = vec![ "git".to_string(), - "clone".to_string(), - "--no-checkout".to_string(), - cache_path.clone(), - path.clone(), + "diff".to_string(), + "--name-status".to_string(), + format!("{}...{}", remote_branch, branch), + "--".to_string(), ]; - base.extend(clone_flags); - base - }, - vec![ + let mut output = String::new(); + if self.inner.process.borrow_mut().execute_args( + &command, + &mut output, + Some(path.clone()), + ) != 0 + { + return Err(RuntimeException { + message: format!( + "Failed to execute {}\n\n{}", + implode(" ", &command), + self.inner.process.borrow().get_error_output(), + ), + code: 0, + } + .into()); + } + + let output = trim(&output, None); + // keep the shortest diff from all remote branches we compare against + if unpushed_changes.is_none() + || strlen(&output) < strlen(unpushed_changes.as_deref().unwrap_or("")) + { + unpushed_changes = Some(output); + } + } + } + + // first pass and we found unpushed changes, fetch from all remotes to make sure we have up to date + // remotes and then try again as outdated remotes can sometimes cause false-positives + if unpushed_changes.is_some() && i == 0 { + let mut output = String::new(); + self.inner.process.borrow_mut().execute_args( + &vec!["git".to_string(), "fetch".to_string(), "--all".to_string()], + &mut output, + Some(path.clone()), + ); + + // update list of refs after fetching + let command = vec![ "git".to_string(), - "remote".to_string(), - "set-url".to_string(), - "origin".to_string(), - "--".to_string(), - "%sanitizedUrl%".to_string(), - ], - vec![ - "git".to_string(), - "remote".to_string(), - "add".to_string(), - "composer".to_string(), - "--".to_string(), - "%sanitizedUrl%".to_string(), - ], - ]; - } else { - msg = format!("Cloning {}", self.get_short_hash(&r#ref)); - commands = vec![ - vec![ - "git".to_string(), - "clone".to_string(), - "--no-checkout".to_string(), - "--".to_string(), - "%url%".to_string(), - path.clone(), - ], - vec![ - "git".to_string(), - "remote".to_string(), - "add".to_string(), - "composer".to_string(), - "--".to_string(), - "%url%".to_string(), - ], - vec![ - "git".to_string(), - "fetch".to_string(), - "composer".to_string(), - ], - vec![ - "git".to_string(), - "remote".to_string(), - "set-url".to_string(), - "origin".to_string(), - "--".to_string(), - "%sanitizedUrl%".to_string(), - ], - vec![ - "git".to_string(), - "remote".to_string(), - "set-url".to_string(), - "composer".to_string(), - "--".to_string(), - "%sanitizedUrl%".to_string(), - ], - ]; - if Platform::get_env("COMPOSER_DISABLE_NETWORK").is_some() { - return Err(RuntimeException { - message: format!( - "The required git reference for {} is not in cache and network is disabled, aborting", - package.get_name(), - ), - code: 0, + "show-ref".to_string(), + "--head".to_string(), + "-d".to_string(), + ]; + let mut output = String::new(); + if self.inner.process.borrow_mut().execute_args( + &command, + &mut output, + Some(path.clone()), + ) != 0 + { + return Err(RuntimeException { + message: format!( + "Failed to execute {}\n\n{}", + implode(" ", &command), + self.inner.process.borrow().get_error_output(), + ), + code: 0, + } + .into()); } - .into()); + refs = trim(&output, None); } - } - self.inner.io.write_error3(&msg, true, io_interface::NORMAL); - - self.git_util - .run_commands(commands, url, Some(&path), true, None)?; - - let source_url = package.get_source_url(); - if Some(url) != source_url.as_deref() && source_url.is_some() { - self.update_origin_url(&path, source_url.as_deref().unwrap()); - } else { - self.set_push_url(&path, url); - } - - let pretty_version = package.get_pretty_version(); - if let Some(new_ref) = - self.update_to_commit(package.clone(), &path, &r#ref, &pretty_version)? - { - if package.get_dist_reference() == package.get_source_reference() { - // TODO(phase-b): set_dist_reference requires &mut PackageInterface - // package.set_dist_reference(Some(new_ref.clone())); + // abort after first pass if we didn't find anything + if unpushed_changes.is_none() { + break; } - // package.set_source_reference(Some(new_ref)); - let _ = new_ref; } - Ok(None) + Ok(unpushed_changes) } - pub(crate) async fn do_update( + /// Updates the given path to the given commit ref + /// + /// @throws \RuntimeException + /// @return null|string if a string is returned, it is the commit reference that was checked out if the original could not be found + pub(crate) fn update_to_commit( &mut self, - _initial: PackageInterfaceHandle, - target: PackageInterfaceHandle, + package: PackageInterfaceHandle, path: &str, - url: &str, - ) -> Result> { - GitUtil::clean_env(&self.inner.process); - let path = self.normalize_path(path); - if !self.has_metadata_repository(&path) { - return Err(RuntimeException { - message: format!( - "The .git directory is missing from {}, see https://getcomposer.org/commit-deps for more information", - path - ), - code: 0, - } - .into()); - } - - let cache_path = format!( - "{}/{}/", - self.inner - .config - .borrow_mut() - .get("cache-vcs-dir") - .as_string() - .unwrap_or(""), - Preg::replace(r"{[^a-z0-9.]}i", "-", &Url::sanitize(url.to_string()))?, - ); - let r#ref = target.get_source_reference().unwrap_or_default(); - - let msg; - let remote_url; - let has_cached = self - .cached_packages - .get(&target.get_id()) - .and_then(|m| m.get(&r#ref)) + reference: &str, + pretty_version: &str, + ) -> Result> { + let force: Vec = if self + .has_discarded_changes + .get(path) .copied() - .unwrap_or(false); - if has_cached { - msg = format!("Checking out {} from cache", self.get_short_hash(&r#ref)); - remote_url = cache_path.clone(); + .unwrap_or(false) + || self.has_stashed_changes.get(path).copied().unwrap_or(false) + { + vec!["-f".to_string()] } else { - msg = format!("Checking out {}", self.get_short_hash(&r#ref)); - remote_url = "%url%".to_string(); - if Platform::get_env("COMPOSER_DISABLE_NETWORK").is_some() { - return Err(RuntimeException { - message: format!( - "The required git reference for {} is not in cache and network is disabled, aborting", - target.get_name(), - ), - code: 0, - } - .into()); - } - } - - self.inner.io.write_error3(&msg, true, io_interface::NORMAL); + vec![] + }; - let mut output = String::new(); - if self.inner.process.borrow_mut().execute_args( - &vec![ - "git".to_string(), - "rev-parse".to_string(), - "--quiet".to_string(), - "--verify".to_string(), - format!("{}^{{commit}}", r#ref), - ], - &mut output, - Some(path.clone()), - ) != 0 - { - let commands = vec![ - vec![ - "git".to_string(), - "remote".to_string(), - "set-url".to_string(), - "composer".to_string(), - "--".to_string(), - remote_url.clone(), - ], - vec![ - "git".to_string(), - "fetch".to_string(), - "composer".to_string(), - ], - vec![ - "git".to_string(), - "fetch".to_string(), - "--tags".to_string(), - "composer".to_string(), - ], - ]; + // This uses the "--" sequence to separate branch from file parameters. + // + // Otherwise git tries the branch name as well as file name. + // If the non-existent branch is actually the name of a file, the file + // is checked out. - self.git_util - .run_commands(commands, url, Some(&path), false, None)?; - } + let mut branch = Preg::replace(r"{(?:^dev-|(?:\.x)?-dev$)}i", "", &pretty_version)?; - let command = vec![ - "git".to_string(), - "remote".to_string(), - "set-url".to_string(), - "composer".to_string(), - "--".to_string(), - "%sanitizedUrl%".to_string(), - ]; - self.git_util - .run_commands(vec![command], url, Some(&path), false, None)?; + // Closure equivalent: $execute = function(array $command) use (&$output, $path) { ... }; + // Inlined below at each call site. - let pretty_version = target.get_pretty_version(); - if let Some(new_ref) = - self.update_to_commit(target.clone(), &path, &r#ref, &pretty_version)? + let mut branches: Option = None; { - if target.get_dist_reference() == target.get_source_reference() { - // TODO(phase-b): set_dist_reference requires &mut PackageInterface - // target.set_dist_reference(Some(new_ref.clone())); + let mut output = String::new(); + if self.inner.process.borrow_mut().execute_args( + &vec!["git".to_string(), "branch".to_string(), "-r".to_string()], + &mut output, + Some(path.to_string()), + ) == 0 + { + branches = Some(output); } - // target.set_source_reference(Some(new_ref)); - let _ = new_ref; } - let mut update_origin_url = false; - let mut output = String::new(); - if self.inner.process.borrow_mut().execute_args( - &vec!["git".to_string(), "remote".to_string(), "-v".to_string()], - &mut output, - Some(path.clone()), - ) == 0 - { - let mut origin_match: IndexMap = IndexMap::new(); - let mut composer_match: IndexMap = IndexMap::new(); - if Preg::is_match3( - r"{^origin\s+(?P\S+)}m", - &output, - Some(&mut origin_match), + // check whether non-commitish are branches or tags, and fetch branches with the remote name + let git_ref = reference.to_string(); + if !Preg::is_match(r"{^[a-f0-9]{40}$}", reference).unwrap_or(false) + && branches.is_some() + && Preg::is_match( + &format!("{{^\\s+composer/{}$}}m", preg_quote(reference, None)), + branches.as_deref().unwrap_or(""), ) .unwrap_or(false) - && Preg::is_match3( - r"{^composer\s+(?P\S+)}m", - &output, - Some(&mut composer_match), + { + let mut command1: Vec = vec!["git".to_string(), "checkout".to_string()]; + command1.extend(force.clone()); + command1.extend(vec![ + "-B".to_string(), + branch.clone(), + format!("composer/{}", reference), + "--".to_string(), + ]); + let command2 = vec![ + "git".to_string(), + "reset".to_string(), + "--hard".to_string(), + format!("composer/{}", reference), + "--".to_string(), + ]; + + let mut output = String::new(); + let ok1 = self.inner.process.borrow_mut().execute_args( + &command1, + &mut output, + Some(path.to_string()), + ) == 0; + let ok2 = if ok1 { + let mut output = String::new(); + self.inner.process.borrow_mut().execute_args( + &command2, + &mut output, + Some(path.to_string()), + ) == 0 + } else { + false + }; + if ok1 && ok2 { + return Ok(None); + } + } + + // try to checkout branch by name and then reset it so it's on the proper branch name + if Preg::is_match(r"{^[a-f0-9]{40}$}", reference).unwrap_or(false) { + // add 'v' in front of the branch if it was stripped when generating the pretty name + if branches.is_some() + && !Preg::is_match( + &format!("{{^\\s+composer/{}$}}m", preg_quote(&branch, None)), + branches.as_deref().unwrap_or(""), + ) + .unwrap_or(false) + && Preg::is_match( + &format!("{{^\\s+composer/v{}$}}m", preg_quote(&branch, None)), + branches.as_deref().unwrap_or(""), ) .unwrap_or(false) { - let origin_url = origin_match - .get(&CaptureKey::ByName("url".to_string())) - .cloned() - .unwrap_or_default(); - let composer_url = composer_match - .get(&CaptureKey::ByName("url".to_string())) - .cloned() - .unwrap_or_default(); - if origin_url == composer_url - && Some(composer_url.as_str()) != target.get_source_url().as_deref() - { - update_origin_url = true; - } + branch = format!("v{}", branch); } - } - if update_origin_url && target.get_source_url().is_some() { - self.update_origin_url(&path, &target.get_source_url().unwrap()); - } - Ok(None) - } + let command = vec![ + "git".to_string(), + "checkout".to_string(), + branch.clone(), + "--".to_string(), + ]; + let mut fallback_command: Vec = vec!["git".to_string(), "checkout".to_string()]; + fallback_command.extend(force.clone()); + fallback_command.extend(vec![ + "-B".to_string(), + branch.clone(), + format!("composer/{}", branch), + "--".to_string(), + ]); + let reset_command = vec![ + "git".to_string(), + "reset".to_string(), + "--hard".to_string(), + reference.to_string(), + "--".to_string(), + ]; - pub fn get_unpushed_changes( - &self, - _package: PackageInterfaceHandle, - path: &str, - ) -> Result> { - GitUtil::clean_env(&self.inner.process); - let path = self.normalize_path(path); - if !self.has_metadata_repository(&path) { - return Ok(None); + let mut output = String::new(); + let ok_command = self.inner.process.borrow_mut().execute_args( + &command, + &mut output, + Some(path.to_string()), + ) == 0; + let ok_fallback = if !ok_command { + let mut output = String::new(); + self.inner.process.borrow_mut().execute_args( + &fallback_command, + &mut output, + Some(path.to_string()), + ) == 0 + } else { + false + }; + let ok_reset = if ok_command || ok_fallback { + let mut output = String::new(); + self.inner.process.borrow_mut().execute_args( + &reset_command, + &mut output, + Some(path.to_string()), + ) == 0 + } else { + false + }; + if (ok_command || ok_fallback) && ok_reset { + return Ok(None); + } } - let command = vec![ + let mut command1: Vec = vec!["git".to_string(), "checkout".to_string()]; + command1.extend(force.clone()); + command1.extend(vec![git_ref.clone(), "--".to_string()]); + let command2 = vec![ "git".to_string(), - "show-ref".to_string(), - "--head".to_string(), - "-d".to_string(), + "reset".to_string(), + "--hard".to_string(), + git_ref.clone(), + "--".to_string(), ]; - let mut output = String::new(); - if self - .inner - .process - .borrow_mut() - .execute_args(&command, &mut output, Some(path.clone())) - != 0 { - return Err(RuntimeException { - message: format!( - "Failed to execute {}\n\n{}", - implode(" ", &command), - self.inner.process.borrow().get_error_output(), - ), - code: 0, + let mut output = String::new(); + let ok1 = self.inner.process.borrow_mut().execute_args( + &command1, + &mut output, + Some(path.to_string()), + ) == 0; + let ok2 = if ok1 { + let mut output = String::new(); + self.inner.process.borrow_mut().execute_args( + &command2, + &mut output, + Some(path.to_string()), + ) == 0 + } else { + false + }; + if ok1 && ok2 { + return Ok(None); } - .into()); } - let mut refs = trim(&output, None); - let mut head_match: IndexMap = IndexMap::new(); - if !Preg::is_match_strict_groups3(r"{^([a-f0-9]+) HEAD$}mi", &refs, Some(&mut head_match)) - .unwrap_or(false) - { - // could not match the HEAD for some reason - return Ok(None); - } - let head_ref = head_match - .get(&CaptureKey::ByIndex(1)) - .cloned() - .unwrap_or_default(); + let mut exception_extra = String::new(); - let mut branches_match: IndexMap> = IndexMap::new(); - if !Preg::is_match_all_strict_groups3( - &format!("{{^{} refs/heads/(.+)$}}mi", preg_quote(&head_ref, None)), - &refs, - Some(&mut branches_match), - ) - .unwrap_or(false) - { - // not on a branch, we are either on a not-modified tag or some sort of detached head, so skip this - return Ok(None); + // reference was not found (prints "fatal: reference is not a tree: $ref") + if strpos(self.inner.process.borrow().get_error_output(), reference).is_some() { + self.inner.io.write_error3( + &format!( + " {} is gone (history was rewritten?)", + reference + ), + true, + io_interface::NORMAL, + ); + exception_extra = format!( + "\nIt looks like the commit hash is not available in the repository, maybe {}? Run \"composer update {}\" to resolve this.", + if package.is_dev() { + "the commit was removed from the branch" + } else { + "the tag was recreated" + }, + package.get_pretty_name(), + ); } - let candidate_branches: Vec = branches_match - .get(&CaptureKey::ByIndex(1)) - .cloned() - .unwrap_or_default(); - - // use the first match as branch name for now - let mut branch = candidate_branches[0].clone(); - let mut unpushed_changes: Option = None; - let mut branch_not_found_error = false; - // do two passes, as if we find anything we want to fetch and then re-try - for i in 0..=1 { - let mut remote_branches: Vec = vec![]; + let command = format!("{} && {}", implode(" ", &command1), implode(" ", &command2)); - // try to find matching branch names in remote repos - for candidate in &candidate_branches { - let mut m: IndexMap> = IndexMap::new(); - if Preg::is_match_all_strict_groups3( - &format!( - "{{^[a-f0-9]+ refs/remotes/((?:[^/]+)/{})$}}mi", - preg_quote(candidate, None) - ), - &refs, - Some(&mut m), - ) - .unwrap_or(false) - { - let matches: Vec = - m.get(&CaptureKey::ByIndex(1)).cloned().unwrap_or_default(); - for match_ in matches { - branch = candidate.clone(); - remote_branches.push(match_); - } - break; - } + Err(RuntimeException { + message: Url::sanitize(format!( + "Failed to execute {}\n\n{}{}", + command, + self.inner.process.borrow().get_error_output(), + exception_extra, + )), + code: 0, + } + .into()) + } + + pub(crate) fn update_origin_url(&mut self, path: &str, url: &str) { + let mut output = String::new(); + self.inner.process.borrow_mut().execute_args( + &vec![ + "git".to_string(), + "remote".to_string(), + "set-url".to_string(), + "origin".to_string(), + "--".to_string(), + url.to_string(), + ], + &mut output, + Some(path.to_string()), + ); + self.set_push_url(path, url); + } + + pub(crate) fn set_push_url(&mut self, path: &str, url: &str) { + // set push url for github projects + let mut match_: IndexMap = IndexMap::new(); + if Preg::is_match3( + &format!( + "{{^(?:https?|git)://{}/([^/]+)/([^/]+?)(?:\\.git)?$}}", + GitUtil::get_github_domains_regex(&*self.inner.config.borrow()) + ), + url, + Some(&mut match_), + ) + .unwrap_or(false) + { + let protocols = self.inner.config.borrow_mut().get("github-protocols"); + let m1 = match_ + .get(&CaptureKey::ByIndex(1)) + .cloned() + .unwrap_or_default(); + let m2 = match_ + .get(&CaptureKey::ByIndex(2)) + .cloned() + .unwrap_or_default(); + let m3 = match_ + .get(&CaptureKey::ByIndex(3)) + .cloned() + .unwrap_or_default(); + let mut push_url = format!("git@{}:{}/{}.git", m1, m2, m3); + if !in_array(PhpMixed::String("ssh".to_string()), &protocols, true) { + push_url = format!("https://{}/{}/{}.git", m1, m2, m3); } + let cmd = vec![ + "git".to_string(), + "remote".to_string(), + "set-url".to_string(), + "--push".to_string(), + "origin".to_string(), + "--".to_string(), + push_url, + ]; + let mut ignored_output = String::new(); + self.inner.process.borrow_mut().execute_args( + &cmd, + &mut ignored_output, + Some(path.to_string()), + ); + } + } - // if it doesn't exist, then we assume it is an unpushed branch - // this is bad as we have no reference point to do a diff so we just bail listing - // the branch as being unpushed - if remote_branches.is_empty() { - unpushed_changes = Some(format!( - "Branch {} could not be found on any remote and appears to be unpushed", - branch - )); - branch_not_found_error = true; - } else { - // if first iteration found no remote branch but it has now found some, reset $unpushedChanges - // so we get the real diff output no matter its length - if branch_not_found_error { - unpushed_changes = None; - } - for remote_branch in &remote_branches { - let command = vec![ - "git".to_string(), - "diff".to_string(), - "--name-status".to_string(), - format!("{}...{}", remote_branch, branch), - "--".to_string(), - ]; - let mut output = String::new(); - if self.inner.process.borrow_mut().execute_args( - &command, - &mut output, - Some(path.clone()), - ) != 0 - { - return Err(RuntimeException { - message: format!( - "Failed to execute {}\n\n{}", - implode(" ", &command), - self.inner.process.borrow().get_error_output(), - ), - code: 0, - } - .into()); - } + /// @phpstan-return PromiseInterface + /// @throws \RuntimeException + pub(crate) async fn discard_changes(&mut self, path: &str) -> Result> { + let path = self.normalize_path(path); + let mut output = String::new(); + if self.inner.process.borrow_mut().execute_args( + &vec!["git".to_string(), "clean".to_string(), "-df".to_string()], + &mut output, + Some(path.clone()), + ) != 0 + { + return Err(RuntimeException { + message: format!("Could not reset changes\n\n:{}", output), + code: 0, + } + .into()); + } + let mut output = String::new(); + if self.inner.process.borrow_mut().execute_args( + &vec!["git".to_string(), "reset".to_string(), "--hard".to_string()], + &mut output, + Some(path.clone()), + ) != 0 + { + return Err(RuntimeException { + message: format!("Could not reset changes\n\n:{}", output), + code: 0, + } + .into()); + } - let output = trim(&output, None); - // keep the shortest diff from all remote branches we compare against - if unpushed_changes.is_none() - || strlen(&output) < strlen(unpushed_changes.as_deref().unwrap_or("")) - { - unpushed_changes = Some(output); - } - } + self.has_discarded_changes.insert(path, true); + + Ok(None) + } + + /// @phpstan-return PromiseInterface + /// @throws \RuntimeException + pub(crate) async fn stash_changes(&mut self, path: &str) -> Result> { + let path = self.normalize_path(path); + let mut output = String::new(); + if self.inner.process.borrow_mut().execute_args( + &vec![ + "git".to_string(), + "stash".to_string(), + "--include-untracked".to_string(), + ], + &mut output, + Some(path.clone()), + ) != 0 + { + return Err(RuntimeException { + message: format!("Could not stash changes\n\n:{}", output), + code: 0, } + .into()); + } - // first pass and we found unpushed changes, fetch from all remotes to make sure we have up to date - // remotes and then try again as outdated remotes can sometimes cause false-positives - if unpushed_changes.is_some() && i == 0 { - let mut output = String::new(); - self.inner.process.borrow_mut().execute_args( - &vec!["git".to_string(), "fetch".to_string(), "--all".to_string()], - &mut output, - Some(path.clone()), - ); + self.has_stashed_changes.insert(path, true); - // update list of refs after fetching - let command = vec![ - "git".to_string(), - "show-ref".to_string(), - "--head".to_string(), - "-d".to_string(), - ]; - let mut output = String::new(); - if self.inner.process.borrow_mut().execute_args( - &command, - &mut output, - Some(path.clone()), - ) != 0 - { - return Err(RuntimeException { - message: format!( - "Failed to execute {}\n\n{}", - implode(" ", &command), - self.inner.process.borrow().get_error_output(), - ), - code: 0, - } - .into()); - } - refs = trim(&output, None); + Ok(None) + } + + /// @throws \RuntimeException + pub(crate) fn view_diff(&mut self, path: &str) { + let path = self.normalize_path(path); + let mut output = String::new(); + if self.inner.process.borrow_mut().execute_args( + &vec!["git".to_string(), "diff".to_string(), "HEAD".to_string()], + &mut output, + Some(path.clone()), + ) != 0 + { + // TODO(phase-b): cannot throw from non-Result fn; bubble error via Result later + panic!("{}", format!("Could not view diff\n\n:{}", output)); + } + + self.inner + .io + .write_error3(&output, true, io_interface::NORMAL); + } + + pub(crate) fn normalize_path(&self, path: &str) -> String { + let mut path = path.to_string(); + if Platform::is_windows() && strlen(&path) > 0 { + let mut base_path = path.clone(); + let mut removed: Vec = vec![]; + + while !is_dir(&base_path) && base_path != "\\" { + let mut new_removed = vec![basename(&base_path)]; + new_removed.extend(removed); + removed = new_removed; + base_path = dirname(&base_path); } - // abort after first pass if we didn't find anything - if unpushed_changes.is_none() { - break; + if base_path == "\\" { + return path; } + + path = rtrim( + &format!( + "{}/{}", + realpath(&base_path).unwrap_or_default(), + implode("/", &removed), + ), + Some("/"), + ); } - Ok(unpushed_changes) + path + } + + pub(crate) fn get_short_hash(&self, reference: &str) -> String { + if !self.inner.io.is_verbose() + && Preg::is_match(r"{^[0-9a-f]{40}$}", reference).unwrap_or(false) + { + return substr(reference, 0, Some(10)); + } + + reference.to_string() + } +} + +impl DvcsDownloaderInterface for GitDownloader { + fn get_unpushed_changes( + &self, + package: PackageInterfaceHandle, + path: String, + ) -> Result> { + GitDownloader::get_unpushed_changes(self, package, &path) + } +} + +impl ChangeReportInterface for GitDownloader { + fn get_local_changes( + &mut self, + _package: PackageInterfaceHandle, + path: &str, + ) -> Result> { + GitUtil::clean_env(&self.inner.process); + if !self.has_metadata_repository(path) { + return Ok(None); + } + + let command = vec![ + "git".to_string(), + "status".to_string(), + "--porcelain".to_string(), + "--untracked-files=no".to_string(), + ]; + let mut output = String::new(); + if self.inner.process.borrow_mut().execute_args( + &command, + &mut output, + Some(path.to_string()), + ) != 0 + { + return Err(RuntimeException { + message: format!( + "Failed to execute {}\n\n{}", + implode(" ", &command), + self.inner.process.borrow().get_error_output(), + ), + code: 0, + } + .into()); + } + + let output = trim(&output, None); + + Ok(if strlen(&output) > 0 { + Some(output) + } else { + None + }) + } +} + +impl VcsCapableDownloaderInterface for GitDownloader { + fn get_vcs_reference(&self, package: PackageInterfaceHandle, path: String) -> Option { + self.inner.get_vcs_reference(package, &path) + } +} + +impl VcsDownloader for GitDownloader { + fn io(&self) -> std::rc::Rc> { + self.inner.io.clone() + } + + fn config(&self) -> &std::rc::Rc> { + &self.inner.config + } + + fn process(&self) -> &std::rc::Rc> { + &self.inner.process + } + + fn filesystem(&self) -> &std::rc::Rc> { + &self.inner.filesystem + } + + fn has_cleaned_changes(&self) -> &IndexMap { + &self.inner.has_cleaned_changes } - pub(crate) async fn clean_changes( + fn has_cleaned_changes_mut(&mut self) -> &mut IndexMap { + &mut self.inner.has_cleaned_changes + } + + async fn do_download( &mut self, package: PackageInterfaceHandle, path: &str, - update: bool, + url: &str, + prev_package: Option, ) -> Result> { + // Do not create an extra local cache when repository is already local + if Filesystem::is_local_path(url) { + return Ok(None); + } + GitUtil::clean_env(&self.inner.process); - let path = self.normalize_path(path); - let unpushed = self.get_unpushed_changes(package.clone(), &path)?; - if let Some(unpushed) = unpushed.as_deref() { - if self.inner.io.is_interactive() - || self - .inner - .config - .borrow_mut() - .get("discard-changes") - .as_bool() - != Some(true) + let cache_path = format!( + "{}/{}/", + self.inner + .config + .borrow_mut() + .get("cache-vcs-dir") + .as_string() + .unwrap_or(""), + Preg::replace(r"{[^a-z0-9.]}i", "-", &Url::sanitize(url.to_string()))?, + ); + let git_version = GitUtil::get_version(&self.inner.process); + + // --dissociate option is only available since git 2.3.0-rc0 + if git_version.is_some() + && version_compare(git_version.as_deref().unwrap_or(""), "2.3.0-rc0", ">=") + && Cache::is_usable(&cache_path) + { + self.inner.io.write_error3( + &format!( + " - Syncing {} ({}) into cache", + package.get_name(), + package + .get_full_pretty_version(true, crate::package::DisplayMode::SourceRefIfDev), + ), + true, + io_interface::NORMAL, + ); + self.inner.io.write_error3( + &sprintf( + " Cloning to cache at %s", + &[PhpMixed::String(cache_path.clone())], + ), + true, + io_interface::DEBUG, + ); + let r#ref = package.get_source_reference(); + let pretty_version = package.get_pretty_version(); + if self.git_util.fetch_ref_or_sync_mirror( + url, + &cache_path, + r#ref.as_deref().unwrap_or(""), + Some(&pretty_version), + )? && is_dir(&cache_path) { + self.cached_packages + .entry(package.get_id()) + .or_insert_with(IndexMap::new) + .insert(r#ref.as_deref().unwrap_or("").to_string(), true); + } + } else if git_version.is_none() { + return Err(RuntimeException { + message: "git was not found in your PATH, skipping source download".to_string(), + code: 0, + } + .into()); + } + + Ok(None) + } + + async fn do_install( + &mut self, + package: PackageInterfaceHandle, + path: &str, + url: &str, + ) -> Result> { + GitUtil::clean_env(&self.inner.process); + let path = self.normalize_path(path); + let cache_path = format!( + "{}/{}/", + self.inner + .config + .borrow_mut() + .get("cache-vcs-dir") + .as_string() + .unwrap_or(""), + Preg::replace(r"{[^a-z0-9.]}i", "-", &Url::sanitize(url.to_string()))?, + ); + let r#ref = package.get_source_reference().unwrap_or_default(); + + let msg; + let commands: Vec>; + let has_cached = self + .cached_packages + .get(&package.get_id()) + .and_then(|m| m.get(&r#ref)) + .copied() + .unwrap_or(false); + if has_cached { + msg = format!("Cloning {} from cache", self.get_short_hash(&r#ref)); + + let mut clone_flags: Vec = vec![ + "--dissociate".to_string(), + "--reference".to_string(), + cache_path.clone(), + ]; + let transport_options = package.get_transport_options(); + if let Some(git_opts) = transport_options.get("git").and_then(|v| v.as_array()) { + if let Some(single) = git_opts.get("single_use_clone").and_then(|v| v.as_bool()) { + if single { + clone_flags = vec![]; + } + } + } + + commands = vec![ + { + let mut base = vec![ + "git".to_string(), + "clone".to_string(), + "--no-checkout".to_string(), + cache_path.clone(), + path.clone(), + ]; + base.extend(clone_flags); + base + }, + vec![ + "git".to_string(), + "remote".to_string(), + "set-url".to_string(), + "origin".to_string(), + "--".to_string(), + "%sanitizedUrl%".to_string(), + ], + vec![ + "git".to_string(), + "remote".to_string(), + "add".to_string(), + "composer".to_string(), + "--".to_string(), + "%sanitizedUrl%".to_string(), + ], + ]; + } else { + msg = format!("Cloning {}", self.get_short_hash(&r#ref)); + commands = vec![ + vec![ + "git".to_string(), + "clone".to_string(), + "--no-checkout".to_string(), + "--".to_string(), + "%url%".to_string(), + path.clone(), + ], + vec![ + "git".to_string(), + "remote".to_string(), + "add".to_string(), + "composer".to_string(), + "--".to_string(), + "%url%".to_string(), + ], + vec![ + "git".to_string(), + "fetch".to_string(), + "composer".to_string(), + ], + vec![ + "git".to_string(), + "remote".to_string(), + "set-url".to_string(), + "origin".to_string(), + "--".to_string(), + "%sanitizedUrl%".to_string(), + ], + vec![ + "git".to_string(), + "remote".to_string(), + "set-url".to_string(), + "composer".to_string(), + "--".to_string(), + "%sanitizedUrl%".to_string(), + ], + ]; + if Platform::get_env("COMPOSER_DISABLE_NETWORK").is_some() { return Err(RuntimeException { message: format!( - "Source directory {} has unpushed changes on the current branch: \n{}", - path, unpushed + "The required git reference for {} is not in cache and network is disabled, aborting", + package.get_name(), ), code: 0, } @@ -680,169 +964,84 @@ impl GitDownloader { } } - let changes = match self.get_local_changes(package.clone(), &path)? { - Some(c) => c, - None => return Ok(None), - }; - - if !self.inner.io.is_interactive() { - let discard_changes = self.inner.config.borrow_mut().get("discard-changes"); - if discard_changes.as_bool() == Some(true) { - return self.discard_changes(&path).await; - } - if discard_changes.as_string() == Some("stash") { - if !update { - return self - .inner - .clean_changes(package.clone(), &path, update) - .await; - } - - return self.stash_changes(&path).await; - } + self.inner.io.write_error3(&msg, true, io_interface::NORMAL); - return self.inner.clean_changes(package, &path, update).await; - } + self.git_util + .run_commands(commands, url, Some(&path), true, None)?; - let changes: Vec = array_map( - |elem: &String| format!(" {}", elem), - &Preg::split(r"{\s*\r?\n\s*}", &changes)?, - ); - self.inner.io.write_error3( - &format!( - " {} has modified files:", - package.get_pretty_name() - ), - true, - io_interface::NORMAL, - ); - let slice_end = 10_usize.min(changes.len()); - // TODO(phase-b): PHP passes the list directly to writeError; joined here so write_error3 takes &str - self.inner - .io - .write_error3(&changes[..slice_end].join("\n"), true, io_interface::NORMAL); - if (changes.len() as i64) > 10 { - self.inner.io.write_error3( - &format!( - " {} more files modified, choose \"v\" to view the full list", - changes.len() as i64 - 10 - ), - true, - io_interface::NORMAL, - ); + let source_url = package.get_source_url(); + if Some(url) != source_url.as_deref() && source_url.is_some() { + self.update_origin_url(&path, source_url.as_deref().unwrap()); + } else { + self.set_push_url(&path, url); } - 'outer: loop { - let answer = self - .inner - .io - .ask( - format!( - " Discard changes [y,n,v,{}?]? ", - if update { "s," } else { "" } - ), - PhpMixed::String("?".to_string()), - ) - .as_string() - .map(|s| s.to_string()); - let mut do_help = false; - match answer.as_deref() { - Some("y") => { - self.discard_changes(&path).await?; - break 'outer; - } - Some("s") => { - if !update { - // goto help; - do_help = true; - } else { - self.stash_changes(&path).await?; - break 'outer; - } - } - Some("n") => { - return Err(RuntimeException { - message: "Update aborted".to_string(), - code: 0, - } - .into()); - } - Some("v") => { - // TODO(phase-b): PHP passes list directly; joined here for &str arg - self.inner - .io - .write_error3(&changes.join("\n"), true, io_interface::NORMAL); - } - Some("d") => { - self.view_diff(&path); - } - _ => { - // case '?': default: - do_help = true; - } - } - - if do_help { - // help: - // TODO(phase-b): PHP passes list directly; joined here for &str arg - self.inner.io.write_error3( - &[ - format!( - " y - discard changes and apply the {}", - if update { "update" } else { "uninstall" } - ), - format!( - " n - abort the {} and let you manually clean things up", - if update { "update" } else { "uninstall" } - ), - " v - view modified files".to_string(), - " d - view local modifications (diff)".to_string(), - ] - .join("\n"), - true, - io_interface::NORMAL, - ); - if update { - self.inner.io.write_error3( - " s - stash changes and try to reapply them after the update", - true, - io_interface::NORMAL, - ); - } - self.inner - .io - .write_error3(" ? - print help", true, io_interface::NORMAL); + let pretty_version = package.get_pretty_version(); + if let Some(new_ref) = + self.update_to_commit(package.clone(), &path, &r#ref, &pretty_version)? + { + if package.get_dist_reference() == package.get_source_reference() { + // TODO(phase-b): set_dist_reference requires &mut PackageInterface + // package.set_dist_reference(Some(new_ref.clone())); } + // package.set_source_reference(Some(new_ref)); + let _ = new_ref; } Ok(None) } - pub(crate) fn reapply_changes(&mut self, path: &str) -> Result<()> { + async fn do_update( + &mut self, + initial: PackageInterfaceHandle, + target: PackageInterfaceHandle, + path: &str, + url: &str, + ) -> Result> { + GitUtil::clean_env(&self.inner.process); let path = self.normalize_path(path); - if self - .has_stashed_changes - .get(&path) + if !self.has_metadata_repository(&path) { + return Err(RuntimeException { + message: format!( + "The .git directory is missing from {}, see https://getcomposer.org/commit-deps for more information", + path + ), + code: 0, + } + .into()); + } + + let cache_path = format!( + "{}/{}/", + self.inner + .config + .borrow_mut() + .get("cache-vcs-dir") + .as_string() + .unwrap_or(""), + Preg::replace(r"{[^a-z0-9.]}i", "-", &Url::sanitize(url.to_string()))?, + ); + let r#ref = target.get_source_reference().unwrap_or_default(); + + let msg; + let remote_url; + let has_cached = self + .cached_packages + .get(&target.get_id()) + .and_then(|m| m.get(&r#ref)) .copied() - .unwrap_or(false) - { - self.has_stashed_changes.shift_remove(&path); - self.inner.io.write_error3( - " Re-applying stashed changes", - true, - io_interface::NORMAL, - ); - let mut output = String::new(); - if self.inner.process.borrow_mut().execute_args( - &vec!["git".to_string(), "stash".to_string(), "pop".to_string()], - &mut output, - Some(path.clone()), - ) != 0 - { + .unwrap_or(false); + if has_cached { + msg = format!("Checking out {} from cache", self.get_short_hash(&r#ref)); + remote_url = cache_path.clone(); + } else { + msg = format!("Checking out {}", self.get_short_hash(&r#ref)); + remote_url = "%url%".to_string(); + if Platform::get_env("COMPOSER_DISABLE_NETWORK").is_some() { return Err(RuntimeException { message: format!( - "Failed to apply stashed changes:\n\n{}", - self.inner.process.borrow().get_error_output() + "The required git reference for {} is not in cache and network is disabled, aborting", + target.get_name(), ), code: 0, } @@ -850,310 +1049,321 @@ impl GitDownloader { } } - self.has_discarded_changes.shift_remove(&path); - Ok(()) - } + self.inner.io.write_error3(&msg, true, io_interface::NORMAL); - /// Updates the given path to the given commit ref - /// - /// @throws \RuntimeException - /// @return null|string if a string is returned, it is the commit reference that was checked out if the original could not be found - pub(crate) fn update_to_commit( - &mut self, - package: PackageInterfaceHandle, - path: &str, - reference: &str, - pretty_version: &str, - ) -> Result> { - let force: Vec = if self - .has_discarded_changes - .get(path) - .copied() - .unwrap_or(false) - || self.has_stashed_changes.get(path).copied().unwrap_or(false) + let mut output = String::new(); + if self.inner.process.borrow_mut().execute_args( + &vec![ + "git".to_string(), + "rev-parse".to_string(), + "--quiet".to_string(), + "--verify".to_string(), + format!("{}^{{commit}}", r#ref), + ], + &mut output, + Some(path.clone()), + ) != 0 { - vec!["-f".to_string()] - } else { - vec![] - }; - - // This uses the "--" sequence to separate branch from file parameters. - // - // Otherwise git tries the branch name as well as file name. - // If the non-existent branch is actually the name of a file, the file - // is checked out. + let commands = vec![ + vec![ + "git".to_string(), + "remote".to_string(), + "set-url".to_string(), + "composer".to_string(), + "--".to_string(), + remote_url.clone(), + ], + vec![ + "git".to_string(), + "fetch".to_string(), + "composer".to_string(), + ], + vec![ + "git".to_string(), + "fetch".to_string(), + "--tags".to_string(), + "composer".to_string(), + ], + ]; - let mut branch = Preg::replace(r"{(?:^dev-|(?:\.x)?-dev$)}i", "", &pretty_version)?; + self.git_util + .run_commands(commands, url, Some(&path), false, None)?; + } - // Closure equivalent: $execute = function(array $command) use (&$output, $path) { ... }; - // Inlined below at each call site. + let command = vec![ + "git".to_string(), + "remote".to_string(), + "set-url".to_string(), + "composer".to_string(), + "--".to_string(), + "%sanitizedUrl%".to_string(), + ]; + self.git_util + .run_commands(vec![command], url, Some(&path), false, None)?; - let mut branches: Option = None; + let pretty_version = target.get_pretty_version(); + if let Some(new_ref) = + self.update_to_commit(target.clone(), &path, &r#ref, &pretty_version)? { - let mut output = String::new(); - if self.inner.process.borrow_mut().execute_args( - &vec!["git".to_string(), "branch".to_string(), "-r".to_string()], - &mut output, - Some(path.to_string()), - ) == 0 - { - branches = Some(output); + if target.get_dist_reference() == target.get_source_reference() { + // TODO(phase-b): set_dist_reference requires &mut PackageInterface + // target.set_dist_reference(Some(new_ref.clone())); } + // target.set_source_reference(Some(new_ref)); + let _ = new_ref; } - // check whether non-commitish are branches or tags, and fetch branches with the remote name - let git_ref = reference.to_string(); - if !Preg::is_match(r"{^[a-f0-9]{40}$}", reference).unwrap_or(false) - && branches.is_some() - && Preg::is_match( - &format!("{{^\\s+composer/{}$}}m", preg_quote(reference, None)), - branches.as_deref().unwrap_or(""), + let mut update_origin_url = false; + let mut output = String::new(); + if self.inner.process.borrow_mut().execute_args( + &vec!["git".to_string(), "remote".to_string(), "-v".to_string()], + &mut output, + Some(path.clone()), + ) == 0 + { + let mut origin_match: IndexMap = IndexMap::new(); + let mut composer_match: IndexMap = IndexMap::new(); + if Preg::is_match3( + r"{^origin\s+(?P\S+)}m", + &output, + Some(&mut origin_match), ) .unwrap_or(false) - { - let mut command1: Vec = vec!["git".to_string(), "checkout".to_string()]; - command1.extend(force.clone()); - command1.extend(vec![ - "-B".to_string(), - branch.clone(), - format!("composer/{}", reference), - "--".to_string(), - ]); - let command2 = vec![ - "git".to_string(), - "reset".to_string(), - "--hard".to_string(), - format!("composer/{}", reference), - "--".to_string(), - ]; - - let mut output = String::new(); - let ok1 = self.inner.process.borrow_mut().execute_args( - &command1, - &mut output, - Some(path.to_string()), - ) == 0; - let ok2 = if ok1 { - let mut output = String::new(); - self.inner.process.borrow_mut().execute_args( - &command2, - &mut output, - Some(path.to_string()), - ) == 0 - } else { - false - }; - if ok1 && ok2 { - return Ok(None); - } - } - - // try to checkout branch by name and then reset it so it's on the proper branch name - if Preg::is_match(r"{^[a-f0-9]{40}$}", reference).unwrap_or(false) { - // add 'v' in front of the branch if it was stripped when generating the pretty name - if branches.is_some() - && !Preg::is_match( - &format!("{{^\\s+composer/{}$}}m", preg_quote(&branch, None)), - branches.as_deref().unwrap_or(""), - ) - .unwrap_or(false) - && Preg::is_match( - &format!("{{^\\s+composer/v{}$}}m", preg_quote(&branch, None)), - branches.as_deref().unwrap_or(""), + && Preg::is_match3( + r"{^composer\s+(?P\S+)}m", + &output, + Some(&mut composer_match), ) .unwrap_or(false) { - branch = format!("v{}", branch); + let origin_url = origin_match + .get(&CaptureKey::ByName("url".to_string())) + .cloned() + .unwrap_or_default(); + let composer_url = composer_match + .get(&CaptureKey::ByName("url".to_string())) + .cloned() + .unwrap_or_default(); + if origin_url == composer_url + && Some(composer_url.as_str()) != target.get_source_url().as_deref() + { + update_origin_url = true; + } } + } + if update_origin_url && target.get_source_url().is_some() { + self.update_origin_url(&path, &target.get_source_url().unwrap()); + } - let command = vec![ - "git".to_string(), - "checkout".to_string(), - branch.clone(), - "--".to_string(), - ]; - let mut fallback_command: Vec = vec!["git".to_string(), "checkout".to_string()]; - fallback_command.extend(force.clone()); - fallback_command.extend(vec![ - "-B".to_string(), - branch.clone(), - format!("composer/{}", branch), - "--".to_string(), - ]); - let reset_command = vec![ - "git".to_string(), - "reset".to_string(), - "--hard".to_string(), - reference.to_string(), - "--".to_string(), - ]; + Ok(None) + } - let mut output = String::new(); - let ok_command = self.inner.process.borrow_mut().execute_args( - &command, - &mut output, - Some(path.to_string()), - ) == 0; - let ok_fallback = if !ok_command { - let mut output = String::new(); - self.inner.process.borrow_mut().execute_args( - &fallback_command, - &mut output, - Some(path.to_string()), - ) == 0 - } else { - false - }; - let ok_reset = if ok_command || ok_fallback { - let mut output = String::new(); - self.inner.process.borrow_mut().execute_args( - &reset_command, - &mut output, - Some(path.to_string()), - ) == 0 - } else { - false - }; - if (ok_command || ok_fallback) && ok_reset { - return Ok(None); + async fn clean_changes( + &mut self, + package: PackageInterfaceHandle, + path: &str, + update: bool, + ) -> Result> { + GitUtil::clean_env(&self.inner.process); + let path = self.normalize_path(path); + + let unpushed = self.get_unpushed_changes(package.clone(), &path)?; + if let Some(unpushed) = unpushed.as_deref() { + if self.inner.io.is_interactive() + || self + .inner + .config + .borrow_mut() + .get("discard-changes") + .as_bool() + != Some(true) + { + return Err(RuntimeException { + message: format!( + "Source directory {} has unpushed changes on the current branch: \n{}", + path, unpushed + ), + code: 0, + } + .into()); } } - let mut command1: Vec = vec!["git".to_string(), "checkout".to_string()]; - command1.extend(force.clone()); - command1.extend(vec![git_ref.clone(), "--".to_string()]); - let command2 = vec![ - "git".to_string(), - "reset".to_string(), - "--hard".to_string(), - git_ref.clone(), - "--".to_string(), - ]; - { - let mut output = String::new(); - let ok1 = self.inner.process.borrow_mut().execute_args( - &command1, - &mut output, - Some(path.to_string()), - ) == 0; - let ok2 = if ok1 { - let mut output = String::new(); - self.inner.process.borrow_mut().execute_args( - &command2, - &mut output, - Some(path.to_string()), - ) == 0 - } else { - false - }; - if ok1 && ok2 { - return Ok(None); + let changes = match self.get_local_changes(package.clone(), &path)? { + Some(c) => c, + None => return Ok(None), + }; + + if !self.inner.io.is_interactive() { + let discard_changes = self.inner.config.borrow_mut().get("discard-changes"); + if discard_changes.as_bool() == Some(true) { + return self.discard_changes(&path).await; } - } + if discard_changes.as_string() == Some("stash") { + if !update { + return self + .inner + .clean_changes(package.clone(), &path, update) + .await; + } - let mut exception_extra = String::new(); + return self.stash_changes(&path).await; + } - // reference was not found (prints "fatal: reference is not a tree: $ref") - if strpos(self.inner.process.borrow().get_error_output(), reference).is_some() { + return self.inner.clean_changes(package, &path, update).await; + } + + let changes: Vec = array_map( + |elem: &String| format!(" {}", elem), + &Preg::split(r"{\s*\r?\n\s*}", &changes)?, + ); + self.inner.io.write_error3( + &format!( + " {} has modified files:", + package.get_pretty_name() + ), + true, + io_interface::NORMAL, + ); + let slice_end = 10_usize.min(changes.len()); + // TODO(phase-b): PHP passes the list directly to writeError; joined here so write_error3 takes &str + self.inner + .io + .write_error3(&changes[..slice_end].join("\n"), true, io_interface::NORMAL); + if (changes.len() as i64) > 10 { self.inner.io.write_error3( &format!( - " {} is gone (history was rewritten?)", - reference + " {} more files modified, choose \"v\" to view the full list", + changes.len() as i64 - 10 ), true, io_interface::NORMAL, ); - exception_extra = format!( - "\nIt looks like the commit hash is not available in the repository, maybe {}? Run \"composer update {}\" to resolve this.", - if package.is_dev() { - "the commit was removed from the branch" - } else { - "the tag was recreated" - }, - package.get_pretty_name(), - ); - } - - let command = format!("{} && {}", implode(" ", &command1), implode(" ", &command2)); - - Err(RuntimeException { - message: Url::sanitize(format!( - "Failed to execute {}\n\n{}{}", - command, - self.inner.process.borrow().get_error_output(), - exception_extra, - )), - code: 0, } - .into()) - } - - pub(crate) fn update_origin_url(&mut self, path: &str, url: &str) { - let mut output = String::new(); - self.inner.process.borrow_mut().execute_args( - &vec![ - "git".to_string(), - "remote".to_string(), - "set-url".to_string(), - "origin".to_string(), - "--".to_string(), - url.to_string(), - ], - &mut output, - Some(path.to_string()), - ); - self.set_push_url(path, url); - } - pub(crate) fn set_push_url(&mut self, path: &str, url: &str) { - // set push url for github projects - let mut match_: IndexMap = IndexMap::new(); - if Preg::is_match3( - &format!( - "{{^(?:https?|git)://{}/([^/]+)/([^/]+?)(?:\\.git)?$}}", - GitUtil::get_github_domains_regex(&*self.inner.config.borrow()) - ), - url, - Some(&mut match_), - ) - .unwrap_or(false) - { - let protocols = self.inner.config.borrow_mut().get("github-protocols"); - let m1 = match_ - .get(&CaptureKey::ByIndex(1)) - .cloned() - .unwrap_or_default(); - let m2 = match_ - .get(&CaptureKey::ByIndex(2)) - .cloned() - .unwrap_or_default(); - let m3 = match_ - .get(&CaptureKey::ByIndex(3)) - .cloned() - .unwrap_or_default(); - let mut push_url = format!("git@{}:{}/{}.git", m1, m2, m3); - if !in_array(PhpMixed::String("ssh".to_string()), &protocols, true) { - push_url = format!("https://{}/{}/{}.git", m1, m2, m3); + 'outer: loop { + let answer = self + .inner + .io + .ask( + format!( + " Discard changes [y,n,v,{}?]? ", + if update { "s," } else { "" } + ), + PhpMixed::String("?".to_string()), + ) + .as_string() + .map(|s| s.to_string()); + let mut do_help = false; + match answer.as_deref() { + Some("y") => { + self.discard_changes(&path).await?; + break 'outer; + } + Some("s") => { + if !update { + // goto help; + do_help = true; + } else { + self.stash_changes(&path).await?; + break 'outer; + } + } + Some("n") => { + return Err(RuntimeException { + message: "Update aborted".to_string(), + code: 0, + } + .into()); + } + Some("v") => { + // TODO(phase-b): PHP passes list directly; joined here for &str arg + self.inner + .io + .write_error3(&changes.join("\n"), true, io_interface::NORMAL); + } + Some("d") => { + self.view_diff(&path); + } + _ => { + // case '?': default: + do_help = true; + } } - let cmd = vec![ - "git".to_string(), - "remote".to_string(), - "set-url".to_string(), - "--push".to_string(), - "origin".to_string(), - "--".to_string(), - push_url, - ]; - let mut ignored_output = String::new(); - self.inner.process.borrow_mut().execute_args( - &cmd, - &mut ignored_output, - Some(path.to_string()), + + if do_help { + // help: + // TODO(phase-b): PHP passes list directly; joined here for &str arg + self.inner.io.write_error3( + &[ + format!( + " y - discard changes and apply the {}", + if update { "update" } else { "uninstall" } + ), + format!( + " n - abort the {} and let you manually clean things up", + if update { "update" } else { "uninstall" } + ), + " v - view modified files".to_string(), + " d - view local modifications (diff)".to_string(), + ] + .join("\n"), + true, + io_interface::NORMAL, + ); + if update { + self.inner.io.write_error3( + " s - stash changes and try to reapply them after the update", + true, + io_interface::NORMAL, + ); + } + self.inner + .io + .write_error3(" ? - print help", true, io_interface::NORMAL); + } + } + + Ok(None) + } + + fn reapply_changes(&mut self, path: &str) -> Result<()> { + let path = self.normalize_path(path); + if self + .has_stashed_changes + .get(&path) + .copied() + .unwrap_or(false) + { + self.has_stashed_changes.shift_remove(&path); + self.inner.io.write_error3( + " Re-applying stashed changes", + true, + io_interface::NORMAL, ); + let mut output = String::new(); + if self.inner.process.borrow_mut().execute_args( + &vec!["git".to_string(), "stash".to_string(), "pop".to_string()], + &mut output, + Some(path.clone()), + ) != 0 + { + return Err(RuntimeException { + message: format!( + "Failed to apply stashed changes:\n\n{}", + self.inner.process.borrow().get_error_output() + ), + code: 0, + } + .into()); + } } + + self.has_discarded_changes.shift_remove(&path); + Ok(()) } - pub(crate) fn get_commit_logs( + fn get_commit_logs( &mut self, from_reference: &str, to_reference: &str, @@ -1189,203 +1399,17 @@ impl GitDownloader { Ok(GitUtil::parse_rev_list_output(&output, &self.inner.process)) } - /// @phpstan-return PromiseInterface - /// @throws \RuntimeException - pub(crate) async fn discard_changes(&mut self, path: &str) -> Result> { - let path = self.normalize_path(path); - let mut output = String::new(); - if self.inner.process.borrow_mut().execute_args( - &vec!["git".to_string(), "clean".to_string(), "-df".to_string()], - &mut output, - Some(path.clone()), - ) != 0 - { - return Err(RuntimeException { - message: format!("Could not reset changes\n\n:{}", output), - code: 0, - } - .into()); - } - let mut output = String::new(); - if self.inner.process.borrow_mut().execute_args( - &vec!["git".to_string(), "reset".to_string(), "--hard".to_string()], - &mut output, - Some(path.clone()), - ) != 0 - { - return Err(RuntimeException { - message: format!("Could not reset changes\n\n:{}", output), - code: 0, - } - .into()); - } - - self.has_discarded_changes.insert(path, true); - - Ok(None) - } - - /// @phpstan-return PromiseInterface - /// @throws \RuntimeException - pub(crate) async fn stash_changes(&mut self, path: &str) -> Result> { - let path = self.normalize_path(path); - let mut output = String::new(); - if self.inner.process.borrow_mut().execute_args( - &vec![ - "git".to_string(), - "stash".to_string(), - "--include-untracked".to_string(), - ], - &mut output, - Some(path.clone()), - ) != 0 - { - return Err(RuntimeException { - message: format!("Could not stash changes\n\n:{}", output), - code: 0, - } - .into()); - } - - self.has_stashed_changes.insert(path, true); - - Ok(None) - } - - /// @throws \RuntimeException - pub(crate) fn view_diff(&mut self, path: &str) { - let path = self.normalize_path(path); - let mut output = String::new(); - if self.inner.process.borrow_mut().execute_args( - &vec!["git".to_string(), "diff".to_string(), "HEAD".to_string()], - &mut output, - Some(path.clone()), - ) != 0 - { - // TODO(phase-b): cannot throw from non-Result fn; bubble error via Result later - panic!("{}", format!("Could not view diff\n\n:{}", output)); - } - - self.inner - .io - .write_error3(&output, true, io_interface::NORMAL); - } - - pub(crate) fn normalize_path(&self, path: &str) -> String { - let mut path = path.to_string(); - if Platform::is_windows() && strlen(&path) > 0 { - let mut base_path = path.clone(); - let mut removed: Vec = vec![]; - - while !is_dir(&base_path) && base_path != "\\" { - let mut new_removed = vec![basename(&base_path)]; - new_removed.extend(removed); - removed = new_removed; - base_path = dirname(&base_path); - } - - if base_path == "\\" { - return path; - } - - path = rtrim( - &format!( - "{}/{}", - realpath(&base_path).unwrap_or_default(), - implode("/", &removed), - ), - Some("/"), - ); - } - - path - } - - pub(crate) fn has_metadata_repository(&self, path: &str) -> bool { + fn has_metadata_repository(&self, path: &str) -> bool { let path = self.normalize_path(path); is_dir(&format!("{}/.git", path)) } - - pub(crate) fn get_short_hash(&self, reference: &str) -> String { - if !self.inner.io.is_verbose() - && Preg::is_match(r"{^[0-9a-f]{40}$}", reference).unwrap_or(false) - { - return substr(reference, 0, Some(10)); - } - - reference.to_string() - } -} - -impl DvcsDownloaderInterface for GitDownloader { - fn get_unpushed_changes( - &self, - package: PackageInterfaceHandle, - path: String, - ) -> Result> { - GitDownloader::get_unpushed_changes(self, package, &path) - } -} - -impl ChangeReportInterface for GitDownloader { - fn get_local_changes( - &self, - _package: PackageInterfaceHandle, - path: &str, - ) -> Result> { - GitUtil::clean_env(&self.inner.process); - if !self.has_metadata_repository(path) { - return Ok(None); - } - - let command = vec![ - "git".to_string(), - "status".to_string(), - "--porcelain".to_string(), - "--untracked-files=no".to_string(), - ]; - let mut output = String::new(); - if self.inner.process.borrow_mut().execute_args( - &command, - &mut output, - Some(path.to_string()), - ) != 0 - { - return Err(RuntimeException { - message: format!( - "Failed to execute {}\n\n{}", - implode(" ", &command), - self.inner.process.borrow().get_error_output(), - ), - code: 0, - } - .into()); - } - - let output = trim(&output, None); - - Ok(if strlen(&output) > 0 { - Some(output) - } else { - None - }) - } -} - -impl VcsCapableDownloaderInterface for GitDownloader { - fn get_vcs_reference(&self, package: PackageInterfaceHandle, path: String) -> Option { - self.inner.get_vcs_reference(package, &path) - } } -// TODO(phase-b): GitDownloader extends VcsDownloader which implements DownloaderInterface. -// Delegating each trait method to todo!() until the inner VcsDownloaderBase exposes the -// matching impl surface. #[async_trait::async_trait(?Send)] impl crate::downloader::DownloaderInterface for GitDownloader { fn get_installation_source(&self) -> String { - todo!() + ::get_installation_source(self) } fn as_dvcs_downloader_interface( @@ -1394,7 +1418,9 @@ impl crate::downloader::DownloaderInterface for GitDownloader { Some(self) } - fn as_change_report_interface(&self) -> Option<&dyn crate::downloader::ChangeReportInterface> { + fn as_change_report_interface( + &mut self, + ) -> Option<&mut dyn crate::downloader::ChangeReportInterface> { Some(self) } @@ -1405,59 +1431,59 @@ impl crate::downloader::DownloaderInterface for GitDownloader { } async fn download( - &self, - _package: PackageInterfaceHandle, - _path: &str, - _prev_package: Option, + &mut self, + package: PackageInterfaceHandle, + path: &str, + prev_package: Option, _output: bool, ) -> anyhow::Result> { - todo!() + ::download(self, package, path, prev_package).await } async fn prepare( - &self, - _type: &str, - _package: PackageInterfaceHandle, - _path: &str, - _prev_package: Option, + &mut self, + r#type: &str, + package: PackageInterfaceHandle, + path: &str, + prev_package: Option, ) -> anyhow::Result> { - todo!() + ::prepare(self, r#type, package, path, prev_package).await } async fn install( - &self, - _package: PackageInterfaceHandle, - _path: &str, + &mut self, + package: PackageInterfaceHandle, + path: &str, _output: bool, ) -> anyhow::Result> { - todo!() + ::install(self, package, path).await } async fn update( - &self, - _initial: PackageInterfaceHandle, - _target: PackageInterfaceHandle, - _path: &str, + &mut self, + initial: PackageInterfaceHandle, + target: PackageInterfaceHandle, + path: &str, ) -> anyhow::Result> { - todo!() + ::update(self, initial, target, path).await } async fn remove( - &self, - _package: PackageInterfaceHandle, - _path: &str, + &mut self, + package: PackageInterfaceHandle, + path: &str, _output: bool, ) -> anyhow::Result> { - todo!() + ::remove(self, package, path).await } async fn cleanup( - &self, - _type: &str, - _package: PackageInterfaceHandle, - _path: &str, - _prev_package: Option, + &mut self, + r#type: &str, + package: PackageInterfaceHandle, + path: &str, + prev_package: Option, ) -> anyhow::Result> { - todo!() + ::cleanup(self, r#type, package, path, prev_package).await } } -- cgit v1.3.1