use std::collections::HashSet;
use std::io;
use std::path::{Path, PathBuf};
use std::process::{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_or_fetch(ctx: &ExecutionContext) -> Result<()> {
let mut repos = RepoStep::try_new()?;
let config = ctx.config();
if config.use_predefined_git_repos() {
{
if config.should_run(Step::Emacs) {
let emacs = Emacs::new();
if !emacs.is_doom()
&& let Some(directory) = emacs.directory()
{
repos.insert_if_repo(ctx, directory);
}
repos.insert_if_repo(ctx, HOME_DIR.join(".doom.d"));
}
if config.should_run(Step::Vim) {
repos.insert_if_repo(ctx, HOME_DIR.join(".vim"));
repos.insert_if_repo(ctx, HOME_DIR.join(".config/nvim"));
}
repos.insert_if_repo(ctx, HOME_DIR.join(".ideavimrc"));
repos.insert_if_repo(ctx, HOME_DIR.join(".intellimacs"));
if config.should_run(Step::Rcm) {
repos.insert_if_repo(ctx, HOME_DIR.join(".dotfiles"));
}
if let Some(powershell) = ctx.powershell()
&& let Some(profile) = powershell.profile()
{
repos.insert_if_repo(ctx, profile);
}
}
#[cfg(unix)]
{
repos.insert_if_repo(ctx, crate::steps::zsh::zshrc());
if config.should_run(Step::Tmux) {
repos.insert_if_repo(ctx, HOME_DIR.join(".tmux"));
}
repos.insert_if_repo(ctx, HOME_DIR.join(".config/fish"));
repos.insert_if_repo(ctx, XDG_DIRS.config_dir().join("openbox"));
repos.insert_if_repo(ctx, XDG_DIRS.config_dir().join("bspwm"));
repos.insert_if_repo(ctx, XDG_DIRS.config_dir().join("i3"));
repos.insert_if_repo(ctx, XDG_DIRS.config_dir().join("sway"));
}
#[cfg(windows)]
{
repos.insert_if_repo(
ctx,
WINDOWS_DIRS
.cache_dir()
.join("Packages/Microsoft.WindowsTerminal_8wekyb3d8bbwe/LocalState"),
);
super::os::windows::insert_startup_scripts(ctx, &mut repos).ok();
}
}
if let Some(custom_git_repos) = config.git_repos() {
for git_repo in custom_git_repos {
repos.glob_insert(ctx, &shellexpand::tilde(git_repo));
}
}
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_or_fetch_repos(ctx)
}
#[cfg(windows)]
static PATH_PREFIX: &str = "\\\\?\\";
pub struct RepoStep {
git: PathBuf,
repos: HashSet<PathBuf>,
glob_match_options: MatchOptions,
bad_patterns: Vec<String>,
}
#[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<P: AsRef<Path>>(ctx: &ExecutionContext, git: &Path, repo: P) -> Option<String> {
ctx.execute(git)
.always()
.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 {
pub fn try_new() -> Result<Self> {
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,
})
}
pub fn get_repo_root<P: AsRef<Path>>(&self, ctx: &ExecutionContext, path: P) -> Option<PathBuf> {
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 = ctx
.execute(&self.git)
.always()
.stdin(Stdio::null())
.current_dir(path)
.args(["rev-parse", "--show-toplevel"])
.output_checked_utf8()
.ok()
.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
}
pub fn insert_if_repo<P: AsRef<Path>>(&mut self, ctx: &ExecutionContext, path: P) -> bool {
if let Some(repo) = self.get_repo_root(ctx, path) {
self.repos.insert(repo);
true
} else {
false
}
}
fn has_remotes<P: AsRef<Path>>(&self, ctx: &ExecutionContext, repo: P) -> Option<bool> {
let mut cmd = ctx.execute(&self.git).always();
cmd.stdin(Stdio::null())
.current_dir(repo.as_ref())
.args(["remote", "-v"]);
let res = cmd.output_checked_utf8();
res.map(|output| {
output
.stdout
.lines()
.any(|line| {
let mut parts = line.split_whitespace();
let name = parts.next();
let url = parts.next();
name.is_some() && url.is_some()
})
})
.map_err(|e| {
error!("Error getting remotes for {}: {e}", repo.as_ref().display());
e
})
.ok()
}
pub fn glob_insert(&mut self, ctx: &ExecutionContext, pattern: &str) {
if let Ok(glob) = glob_with(pattern, self.glob_match_options) {
let mut last_git_repo: Option<PathBuf> = None;
for entry in glob {
match entry {
Ok(path) => {
if let Some(last_git_repo) = &last_git_repo
&& 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(ctx, &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}");
}
}
pub fn is_repos_empty(&self) -> bool {
self.repos.is_empty()
}
#[cfg(unix)]
pub fn remove<P: AsRef<Path>>(&mut self, path: P) {
let _removed = self.repos.remove(path.as_ref());
debug_assert!(_removed);
}
async fn pull_or_fetch_repo<P: AsRef<Path>>(&self, ctx: &ExecutionContext<'_>, repo: P) -> Result<()> {
let before_revision = get_head_revision(ctx, &self.git, &repo);
let is_fetch_only = ctx.config().git_fetch_only();
if ctx.config().verbose() {
let action = if is_fetch_only { t!("Fetching") } else { t!("Pulling") };
println!("{} {}", style(action).cyan().bold(), repo.as_ref().display());
}
let mut command = AsyncCommand::new(&self.git);
command.stdin(Stdio::null()).current_dir(&repo);
if is_fetch_only {
command.args(["fetch", "--recurse-submodules"]);
} else {
command.args(["pull", "--ff-only", "--recurse-submodules"]);
}
if let Some(extra_arguments) = ctx.config().git_arguments() {
command.args(extra_arguments.split_whitespace());
}
let output = command.output().await?;
let result = output_checked_utf8(output).wrap_err_with(|| {
let action = if is_fetch_only { "fetch" } else { "pull" };
format!("Failed to {} {}", action, repo.as_ref().display())
});
if result.is_err() {
let action = if is_fetch_only { t!("fetching") } else { t!("pulling") };
println!(
"{} {} {}",
style(t!("Failed")).red().bold(),
action,
repo.as_ref().display()
);
} else {
let after_revision = get_head_revision(ctx, &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());
ctx.execute(&self.git)
.always()
.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
}
fn pull_or_fetch_repos(&self, ctx: &ExecutionContext) -> Result<()> {
let is_fetch_only = ctx.config().git_fetch_only();
if ctx.run_type().dry() {
self.repos.iter().for_each(|repo| {
let message = if is_fetch_only {
t!("Would fetch {repo}", repo = repo.display())
} else {
t!("Would pull {repo}", repo = repo.display())
};
println!("{}", message);
});
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(ctx, repo) {
Some(false) => {
println!(
"{} {} {}",
style(t!("Skipping")).yellow().bold(),
repo.display(),
t!("because it has no remotes")
);
false
}
_ => true, })
.map(|repo| self.pull_or_fetch_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::<FuturesUnordered<_>>().boxed()
};
let basic_rt = runtime::Runtime::new()?;
let results = basic_rt.block_on(async { stream_of_futures.collect::<Vec<Result<()>>>().await });
let error = results.into_iter().find(std::result::Result::is_err);
error.unwrap_or(Ok(()))
}
}