#![allow(clippy::print_stdout, clippy::unwrap_used)]
use std::{
borrow::ToOwned,
fs,
path::PathBuf,
str,
time::{Duration, Instant},
};
use color_eyre::eyre::{bail, Context, Result};
use git2::{BranchType, ConfigLevel, ErrorCode, FetchOptions, Repository};
use itertools::Itertools;
use log::{debug, trace, warn};
use url::Url;
use crate::tasks::{
git::{
branch::{calculate_head, get_branch_name, get_push_branch, shorten_branch_ref},
checkout::{checkout_branch, needs_checkout},
errors::GitError as E,
fetch::{remote_callbacks, set_remote_head},
merge::do_ff_merge,
prune::prune_merged_branches,
status::warn_for_unpushed_changes,
GitConfig, GitRemote,
},
task::TaskStatus,
};
pub(crate) fn update(git_config: &GitConfig) -> Result<TaskStatus> {
let now = Instant::now();
let result = real_update(git_config)
.map(|did_work| {
if did_work {
TaskStatus::Passed
} else {
TaskStatus::Skipped
}
})
.with_context(|| E::GitUpdate {
path: PathBuf::from(git_config.path.clone()),
});
let elapsed_time = now.elapsed();
if elapsed_time > Duration::from_secs(60) {
warn!(
"Git update for {path} took {elapsed_time:?}",
path = git_config.path
);
}
result
}
#[allow(clippy::too_many_lines)]
pub(crate) fn real_update(git_config: &GitConfig) -> Result<bool> {
let mut did_work = false;
let git_path = PathBuf::from(git_config.path.clone());
debug!("Updating git repo '{git_path:?}'");
let mut newly_created_repo = false;
if !git_path.is_dir() {
debug!("Dir doesn't exist, creating...");
newly_created_repo = true;
fs::create_dir_all(&git_path).map_err(|e| E::CreateDirError {
path: git_path.clone(),
source: e,
})?;
did_work = true;
}
let mut repo = match Repository::open(&git_path) {
Ok(repo) => repo,
Err(e) => {
if e.code() == ErrorCode::NotFound {
newly_created_repo = true;
did_work = true;
Repository::init(&git_path)?
} else {
debug!(
"Failed to open repository: {code:?}\n {e}",
code = e.code()
);
bail!(e);
}
}
};
if newly_created_repo {
debug!("Newly created repo, will force overwrite repo contents.");
}
let mut user_git_config = git2::Config::open_default()?;
let local_git_config_path = git_path.join(".git/config");
if local_git_config_path.exists() {
user_git_config.add_file(&local_git_config_path, ConfigLevel::Local, false)?;
}
for remote_config in &git_config.remotes {
set_up_remote(&repo, remote_config)?;
}
debug!(
"Created remotes: {:?}",
repo.remotes()?.iter().collect::<Vec<_>>()
);
trace!(
"Branches: {:?}",
repo.branches(None)?
.into_iter()
.map_ok(|(branch, _)| get_branch_name(&branch))
.collect::<Vec<_>>()
);
let default_remote_name = git_config.remotes.get(0).ok_or(E::NoRemotes)?.name.clone();
let mut default_remote =
repo.find_remote(&default_remote_name)
.map_err(|e| E::RemoteNotFound {
source: e,
name: default_remote_name.clone(),
})?;
if !newly_created_repo
&& git_config.prune
&& prune_merged_branches(&repo, &default_remote_name)?
{
did_work = true;
}
let branch_name: String = if let Some(branch_name) = &git_config.branch {
branch_name.clone()
} else {
calculate_head(&repo, &mut default_remote)?
};
let short_branch = shorten_branch_ref(&branch_name);
let branch_name = format!("refs/heads/{short_branch}");
if newly_created_repo || needs_checkout(&repo, &branch_name) {
debug!("Checking out branch: {short_branch}");
checkout_branch(
&repo,
&branch_name,
short_branch,
&default_remote_name,
newly_created_repo,
)?;
did_work = true;
}
if let Some(push_branch) = get_push_branch(&repo, short_branch, &user_git_config)? {
debug!("Checking for a @{{push}} branch.");
let push_revision = format!("{short_branch}@{{push}}");
let merge_commit = repo.reference_to_annotated_commit(push_branch.get())?;
let push_branch_name = get_branch_name(&push_branch)?;
if do_ff_merge(&repo, &branch_name, &merge_commit).with_context(|| E::Merge {
branch: branch_name,
merge_rev: push_revision,
merge_ref: push_branch_name,
})? {
did_work = true;
}
} else {
debug!("Branch doesn't have an @{{push}} branch, checking @{{upstream}} instead.");
let up_revision = format!("{short_branch}@{{upstream}}");
match repo
.find_branch(short_branch, BranchType::Local)?
.upstream()
{
Ok(upstream_branch) => {
let upstream_commit = repo.reference_to_annotated_commit(upstream_branch.get())?;
let upstream_branch_name = get_branch_name(&upstream_branch)?;
if do_ff_merge(&repo, &branch_name, &upstream_commit).with_context(|| E::Merge {
branch: branch_name,
merge_rev: up_revision,
merge_ref: upstream_branch_name,
})? {
did_work = true;
}
}
Err(e) if e.code() == ErrorCode::NotFound => {
debug!("Skipping update to remote ref as branch doesn't have an upstream.");
}
Err(e) => {
return Err(e.into());
}
}
};
drop(default_remote); if !newly_created_repo {
warn_for_unpushed_changes(&mut repo, &user_git_config, &git_path)?;
}
Ok(did_work)
}
fn set_up_remote(repo: &Repository, remote_config: &GitRemote) -> Result<bool> {
let mut did_work = false;
let remote_name = &remote_config.name;
let mut remote = repo.find_remote(remote_name).or_else(|e| {
debug!("Finding requested remote failed, creating it (error was: {e})",);
did_work = true;
repo.remote(remote_name, &remote_config.fetch_url)
})?;
if let Some(url) = remote.url() {
if url != remote_config.fetch_url {
debug!(
"Changing remote {remote_name} fetch URL from {url} to {new_url}",
new_url = remote_config.fetch_url
);
repo.remote_set_url(remote_name, &remote_config.fetch_url)?;
did_work = true;
}
}
if let Some(push_url) = &remote_config.push_url {
repo.remote_set_pushurl(remote_name, Some(push_url))?;
did_work = true;
}
let fetch_refspecs: [&str; 0] = [];
{
let mut count = 0;
remote
.fetch(
&fetch_refspecs,
Some(FetchOptions::new().remote_callbacks(remote_callbacks(&mut count))),
Some("up-rs automated fetch"),
)
.map_err(|e| {
let extra_info = if e.to_string()
== "failed to acquire username/password from local configuration"
{
let parsed_result = Url::parse(&remote_config.fetch_url);
let mut protocol = "parse error".to_owned();
let mut host = "parse error".to_owned();
let mut path = "parse error".to_owned();
if let Ok(parsed) = parsed_result {
protocol = parsed.scheme().to_owned();
if let Some(host_str) = parsed.host_str() {
host = host_str.to_owned();
}
path = parsed.path().trim_matches('/').to_owned();
}
let base = if cfg!(target_os = "macos") { format!("\n\n - Check that this command returns 'osxkeychain':\n \
git config credential.helper\n \
If so, set the token with this command (passing in your username and password):\n \
echo -e \"protocol={protocol}\\nhost={host}\\nusername=${{username?}}\\npassword=${{password?}}\" | git credential-osxkeychain store") } else { String::new() };
format!("\n - Check that this command returns a valid username and password (access token):\n \
git credential fill <<< $'protocol={protocol}\\nhost={host}\\npath={path}'\n \
If not see <https://docs.github.com/en/free-pro-team@latest/github/using-git/caching-your-github-credentials-in-git>{base}",
)
} else {
String::new()
};
E::FetchFailed {
remote: remote_name.clone(),
extra_info,
source: e,
}
})?;
}
trace!(
"Remote refs available for {:?}: {:?}",
remote.name(),
remote
.list()?
.iter()
.map(git2::RemoteHead::name)
.collect::<Vec<_>>()
);
let default_branch = remote
.default_branch()?
.as_str()
.map(ToOwned::to_owned)
.ok_or(E::InvalidBranchError)?;
trace!(
"Default branch for remote {:?}: {}",
remote.name(),
&default_branch
);
if set_remote_head(repo, &remote, &default_branch)? {
did_work = true;
};
Ok(did_work)
}
pub(in crate::tasks::git) fn get_config_value(
config: &git2::Config,
key: &str,
) -> Result<Option<String>> {
match config.get_entry(key) {
Ok(push_remote_entry) if push_remote_entry.has_value() => {
let val = push_remote_entry.value().ok_or(E::InvalidBranchError)?;
trace!("Config value for {key} was {val}");
Ok(Some(val.to_owned()))
}
Err(e) if e.code() != ErrorCode::NotFound => {
Err(e.into())
}
_ => {
trace!("Config value {key} was not set");
Ok(None)
}
}
}