use std::collections::HashSet; use std::io; use std::path::{Path, PathBuf}; use std::process::{Command, Output, Stdio}; use color_eyre::eyre::Context; use color_eyre::eyre::{Result, eyre}; use console::style; use futures::stream::{FuturesUnordered, StreamExt, iter}; use glob::{MatchOptions, glob_with}; use tokio::process::Command as AsyncCommand; use tokio::runtime; use tracing::{debug, error}; use crate::command::CommandExt; use crate::execution_context::ExecutionContext; use crate::step::Step; use crate::steps::emacs::Emacs; use crate::terminal::print_separator; use crate::utils::{PathExt, require}; use crate::{HOME_DIR, error::SkipStep, terminal::print_warning}; use etcetera::base_strategy::BaseStrategy; use rust_i18n::t; #[cfg(unix)] use crate::XDG_DIRS; #[cfg(windows)] use crate::WINDOWS_DIRS; pub fn run_git_pull(ctx: &ExecutionContext) -> Result<()> { let mut repos = RepoStep::try_new()?; let config = ctx.config(); // handle built-in repos if config.use_predefined_git_repos() { // should be executed on all the platforms { if config.should_run(Step::Emacs) { let emacs = Emacs::new(); if !emacs.is_doom() { if let Some(directory) = emacs.directory() { repos.insert_if_repo(directory); } } repos.insert_if_repo(HOME_DIR.join(".doom.d")); } if config.should_run(Step::Vim) { repos.insert_if_repo(HOME_DIR.join(".vim")); repos.insert_if_repo(HOME_DIR.join(".config/nvim")); } repos.insert_if_repo(HOME_DIR.join(".ideavimrc")); repos.insert_if_repo(HOME_DIR.join(".intellimacs")); if config.should_run(Step::Rcm) { repos.insert_if_repo(HOME_DIR.join(".dotfiles")); } if let Some(powershell) = ctx.powershell() { if let Some(profile) = powershell.profile() { repos.insert_if_repo(profile); } } } #[cfg(unix)] { repos.insert_if_repo(crate::steps::zsh::zshrc()); if config.should_run(Step::Tmux) { repos.insert_if_repo(HOME_DIR.join(".tmux")); } repos.insert_if_repo(HOME_DIR.join(".config/fish")); repos.insert_if_repo(XDG_DIRS.config_dir().join("openbox")); repos.insert_if_repo(XDG_DIRS.config_dir().join("bspwm")); repos.insert_if_repo(XDG_DIRS.config_dir().join("i3")); repos.insert_if_repo(XDG_DIRS.config_dir().join("sway")); } #[cfg(windows)] { repos.insert_if_repo( WINDOWS_DIRS .cache_dir() .join("Packages/Microsoft.WindowsTerminal_8wekyb3d8bbwe/LocalState"), ); super::os::windows::insert_startup_scripts(&mut repos).ok(); } } // Handle user-defined repos if let Some(custom_git_repos) = config.git_repos() { for git_repo in custom_git_repos { repos.glob_insert(&shellexpand::tilde(git_repo)); } } // Warn the user about the bad patterns. // // NOTE: this should be executed **before** skipping the Git step or the // user won't receive this warning in the cases where all the paths configured // are bad patterns. repos.bad_patterns.iter().for_each(|pattern| { print_warning(t!( "Path {pattern} did not contain any git repositories", pattern = pattern )); }); if repos.is_repos_empty() { return Err(SkipStep(t!("No repositories to pull").to_string()).into()); } print_separator(t!("Git repositories")); repos.pull_repos(ctx) } #[cfg(windows)] static PATH_PREFIX: &str = "\\\\?\\"; pub struct RepoStep { git: PathBuf, repos: HashSet, glob_match_options: MatchOptions, bad_patterns: Vec, } #[track_caller] fn output_checked_utf8(output: Output) -> Result<()> { if !(output.status.success()) { let stderr = String::from_utf8_lossy(&output.stderr); let stderr = stderr.trim(); Err(eyre!("{stderr}")) } else { Ok(()) } } fn get_head_revision>(git: &Path, repo: P) -> Option { Command::new(git) .stdin(Stdio::null()) .current_dir(repo.as_ref()) .args(["rev-parse", "HEAD"]) .output_checked_utf8() .map(|output| output.stdout.trim().to_string()) .map_err(|e| { error!("Error getting revision for {}: {e}", repo.as_ref().display(),); e }) .ok() } impl RepoStep { /// Try to create a `RepoStep`, fail if `git` is not found. pub fn try_new() -> Result { let git = require("git")?; let mut glob_match_options = MatchOptions::new(); if cfg!(windows) { glob_match_options.case_sensitive = false; } Ok(Self { git, repos: HashSet::new(), bad_patterns: Vec::new(), glob_match_options, }) } /// Try to get the root of the repo specified in `path`. pub fn get_repo_root>(&self, path: P) -> Option { match path.as_ref().canonicalize() { Ok(mut path) => { debug_assert!(path.exists()); if path.is_file() { debug!("{} is a file. Checking {}", path.display(), path.parent()?.display()); path = path.parent()?.to_path_buf(); } debug!("Checking if {} is a git repository", path.display()); #[cfg(windows)] let path = { let mut path_string = path.into_os_string().to_string_lossy().into_owned(); if path_string.starts_with(PATH_PREFIX) { path_string.replace_range(0..PATH_PREFIX.len(), ""); } debug!("Transformed path to {}", path_string); path_string }; let output = Command::new(&self.git) .stdin(Stdio::null()) .current_dir(path) .args(["rev-parse", "--show-toplevel"]) .output_checked_utf8() .ok() // trim the last newline char .map(|output| PathBuf::from(output.stdout.trim())); return output; } Err(e) => { if e.kind() == io::ErrorKind::NotFound { debug!("{} does not exist", path.as_ref().display()); } else { error!("Error looking for {}: {e}", path.as_ref().display()); } } } None } /// Check if `path` is a git repo, if yes, add it to `self.repos`. /// /// Return the check result. pub fn insert_if_repo>(&mut self, path: P) -> bool { if let Some(repo) = self.get_repo_root(path) { self.repos.insert(repo); true } else { false } } /// Check if `repo` has a remote. fn has_remotes>(&self, repo: P) -> Option { let mut cmd = Command::new(&self.git); cmd.stdin(Stdio::null()) .current_dir(repo.as_ref()) .args(["remote", "show"]); let res = cmd.output_checked_utf8(); res.map(|output| output.stdout.lines().count() > 0) .map_err(|e| { error!("Error getting remotes for {}: {e}", repo.as_ref().display()); e }) .ok() } /// Similar to `insert_if_repo`, with glob support. pub fn glob_insert(&mut self, pattern: &str) { if let Ok(glob) = glob_with(pattern, self.glob_match_options) { let mut last_git_repo: Option = None; for entry in glob { match entry { Ok(path) => { if let Some(last_git_repo) = &last_git_repo { if path.is_descendant_of(last_git_repo) { debug!( "Skipping {} because it's a descendant of last known repo {}", path.display(), last_git_repo.display() ); continue; } } if self.insert_if_repo(&path) { last_git_repo = Some(path); } } Err(e) => { error!("Error in path {e}"); } } } if last_git_repo.is_none() { self.bad_patterns.push(String::from(pattern)); } } else { error!("Bad glob pattern: {pattern}"); } } /// True if `self.repos` is empty. pub fn is_repos_empty(&self) -> bool { self.repos.is_empty() } /// Remove `path` from `self.repos`. /// // `cfg(unix)` because it is only used in the oh-my-zsh step. #[cfg(unix)] pub fn remove>(&mut self, path: P) { let _removed = self.repos.remove(path.as_ref()); debug_assert!(_removed); } /// Try to pull a repo. async fn pull_repo>(&self, ctx: &ExecutionContext<'_>, repo: P) -> Result<()> { let before_revision = get_head_revision(&self.git, &repo); if ctx.config().verbose() { println!("{} {}", style(t!("Pulling")).cyan().bold(), repo.as_ref().display()); } let mut command = AsyncCommand::new(&self.git); command .stdin(Stdio::null()) .current_dir(&repo) .args(["pull", "--ff-only"]); if let Some(extra_arguments) = ctx.config().git_arguments() { command.args(extra_arguments.split_whitespace()); } let pull_output = command.output().await?; let submodule_output = AsyncCommand::new(&self.git) .args(["submodule", "update", "--recursive"]) .current_dir(&repo) .stdin(Stdio::null()) .output() .await?; let result = output_checked_utf8(pull_output) .and_then(|()| output_checked_utf8(submodule_output)) .wrap_err_with(|| format!("Failed to pull {}", repo.as_ref().display())); if result.is_err() { println!( "{} {} {}", style(t!("Failed")).red().bold(), t!("pulling"), repo.as_ref().display() ); } else { let after_revision = get_head_revision(&self.git, repo.as_ref()); match (&before_revision, &after_revision) { (Some(before), Some(after)) if before != after => { println!("{} {}", style(t!("Changed")).yellow().bold(), repo.as_ref().display()); Command::new(&self.git) .stdin(Stdio::null()) .current_dir(&repo) .args([ "--no-pager", "log", "--no-decorate", "--oneline", &format!("{before}..{after}"), ]) .status_checked()?; println!(); } _ => { if ctx.config().verbose() { println!("{} {}", style(t!("Up-to-date")).green().bold(), repo.as_ref().display()); } } } } result } /// Pull the repositories specified in `self.repos`. /// /// # NOTE /// This function will create an async runtime and do the real job so the /// function itself is not async. fn pull_repos(&self, ctx: &ExecutionContext) -> Result<()> { if ctx.run_type().dry() { self.repos .iter() .for_each(|repo| println!("{}", t!("Would pull {repo}", repo = repo.display()))); return Ok(()); } if !ctx.config().verbose() { println!( "\n{} {}\n", style(t!("Only")).green().bold(), t!("updated repositories will be shown...") ); } let futures_iterator = self .repos .iter() .filter(|repo| match self.has_remotes(repo) { Some(false) => { println!( "{} {} {}", style(t!("Skipping")).yellow().bold(), repo.display(), t!("because it has no remotes") ); false } _ => true, // repo has remotes or command to check for remotes has failed. proceed to pull anyway. }) .map(|repo| self.pull_repo(ctx, repo)); let stream_of_futures = if let Some(limit) = ctx.config().git_concurrency_limit() { iter(futures_iterator).buffer_unordered(limit).boxed() } else { futures_iterator.collect::>().boxed() }; let basic_rt = runtime::Runtime::new()?; let results = basic_rt.block_on(async { stream_of_futures.collect::>>().await }); let error = results.into_iter().find(std::result::Result::is_err); error.unwrap_or(Ok(())) } }