* chore: update to stable toolchain. apply clippy fixes & rustfmt * Bump MSRV * Try MSRV without the patch version * fix: pin toolchain to MSRV * trying again * fix dead code warning --------- Co-authored-by: Dan Sully <dsully@users.noreply.github.com>
420 lines
14 KiB
Rust
420 lines
14 KiB
Rust
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::{eyre, Result};
|
|
use console::style;
|
|
use futures::stream::{iter, FuturesUnordered, StreamExt};
|
|
use glob::{glob_with, MatchOptions};
|
|
use tokio::process::Command as AsyncCommand;
|
|
use tokio::runtime;
|
|
use tracing::{debug, error};
|
|
|
|
use crate::command::CommandExt;
|
|
use crate::config::Step;
|
|
use crate::execution_context::ExecutionContext;
|
|
use crate::steps::emacs::Emacs;
|
|
use crate::terminal::print_separator;
|
|
use crate::utils::{require, PathExt};
|
|
use crate::{error::SkipStep, terminal::print_warning, HOME_DIR};
|
|
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"));
|
|
}
|
|
|
|
let powershell = crate::steps::powershell::Powershell::new();
|
|
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(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<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>>(git: &Path, repo: P) -> Option<String> {
|
|
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<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,
|
|
})
|
|
}
|
|
|
|
/// Try to get the root of the repo specified in `path`.
|
|
pub fn get_repo_root<P: AsRef<Path>>(&self, 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 = 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<P: AsRef<Path>>(&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<P: AsRef<Path>>(&self, repo: P) -> Option<bool> {
|
|
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<PathBuf> = 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<P: AsRef<Path>>(&mut self, path: P) {
|
|
let _removed = self.repos.remove(path.as_ref());
|
|
debug_assert!(_removed);
|
|
}
|
|
|
|
/// Try to pull a repo.
|
|
async fn pull_repo<P: AsRef<Path>>(&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::<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(()))
|
|
}
|
|
}
|