use std::path::Path;
use color_eyre::eyre::{ensure, Result};
use git2::{BranchType, Config, ErrorCode, Repository, StatusOptions, Statuses, SubmoduleIgnore};
use log::{trace, warn};
use crate::tasks::git::{
branch::{get_branch_name, get_push_branch},
cherry::unmerged_commits,
errors::GitError as E,
};
pub(super) fn ensure_repo_clean(repo: &Repository) -> Result<()> {
let statuses = repo_statuses(repo)?;
trace!("Repo statuses: '{}'", status_short(repo, &statuses));
ensure!(
statuses.is_empty(),
E::UncommittedChanges {
status: status_short(repo, &statuses)
}
);
Ok(())
}
pub(super) fn warn_for_unpushed_changes(
repo: &mut Repository,
user_git_config: &Config,
git_path: &Path,
) -> Result<()> {
{
let statuses = repo_statuses(repo)?;
if !statuses.is_empty() {
warn!(
"Repo '{git_path:?}' has uncommitted changes:\n{}",
status_short(repo, &statuses)
);
}
}
{
let mut stash_messages = Vec::new();
repo.stash_foreach(|_index, message, _stash_id| {
stash_messages.push(message.to_owned());
true
})?;
if !stash_messages.is_empty() {
warn!(
"Repo '{git_path:?}' has stashed changes:\n{:#?}",
stash_messages
);
}
}
for branch in repo.branches(Some(BranchType::Local))? {
let branch = branch?.0;
let branch_name = get_branch_name(&branch)?;
if let Some(push_branch) = get_push_branch(repo, &branch_name, user_git_config)? {
if unmerged_commits(repo, &push_branch, &branch)? {
warn!(
"Repo '{git_path:?}' branch '{branch_name}' has changes that aren't in @{{push}}.",
);
}
} else {
match branch.upstream() {
Ok(upstream_branch) => {
if unmerged_commits(repo, &upstream_branch, &branch)? {
warn!(
"Repo '{git_path:?}' branch '{branch_name}' has changes that aren't in @{{upstream}}.",
);
}
}
Err(e) if e.code() == ErrorCode::NotFound => {
warn!(
"Repo '{git_path:?}' branch '{branch_name}' has no @{{upstream}} or @{{push}} branch.",
);
}
Err(e) => {
return Err(e.into());
}
}
}
}
let mut unmerged_branches = Vec::new();
for branch in repo.branches(Some(BranchType::Remote))? {
let branch = branch?.0;
let branch_name = get_branch_name(&branch)?;
if branch_name.contains("fork")
&& !branch_name.contains("HEAD")
&& !branch_name.contains("forkmain")
{
unmerged_branches.push(
branch_name,
);
}
}
if !unmerged_branches.is_empty() {
warn!(
"Repo '{git_path:?}' has unmerged fork branches: {} .",
unmerged_branches.join(" "),
);
}
Ok(())
}
fn repo_statuses(repo: &Repository) -> Result<Statuses> {
let mut status_options = StatusOptions::new();
status_options
.include_ignored(false)
.include_untracked(true);
Ok(repo.statuses(Some(&mut status_options))?)
}
#[allow(clippy::too_many_lines, clippy::useless_let_if_seq)]
fn status_short(repo: &Repository, statuses: &git2::Statuses) -> String {
let mut output = String::new();
for entry in statuses
.iter()
.filter(|e| e.status() != git2::Status::CURRENT)
{
let mut index_status = match entry.status() {
s if s.contains(git2::Status::INDEX_NEW) => 'A',
s if s.contains(git2::Status::INDEX_MODIFIED) => 'M',
s if s.contains(git2::Status::INDEX_DELETED) => 'D',
s if s.contains(git2::Status::INDEX_RENAMED) => 'R',
s if s.contains(git2::Status::INDEX_TYPECHANGE) => 'T',
_ => ' ',
};
let mut worktree_status = match entry.status() {
s if s.contains(git2::Status::WT_NEW) => {
if index_status == ' ' {
index_status = '?';
}
'?'
}
s if s.contains(git2::Status::WT_MODIFIED) => 'M',
s if s.contains(git2::Status::WT_DELETED) => 'D',
s if s.contains(git2::Status::WT_RENAMED) => 'R',
s if s.contains(git2::Status::WT_TYPECHANGE) => 'T',
_ => ' ',
};
if entry.status().contains(git2::Status::IGNORED) {
index_status = '!';
worktree_status = '!';
}
if index_status == '?' && worktree_status == '?' {
continue;
}
let mut extra = "";
let status = entry.index_to_workdir().and_then(|diff| {
let ignore = SubmoduleIgnore::Unspecified;
diff.new_file()
.path_bytes()
.and_then(|s| std::str::from_utf8(s).ok())
.and_then(|name| repo.submodule_status(name, ignore).ok())
});
if let Some(status) = status {
if status.contains(git2::SubmoduleStatus::WD_MODIFIED) {
extra = " (new commits)";
} else if status.contains(git2::SubmoduleStatus::WD_INDEX_MODIFIED)
|| status.contains(git2::SubmoduleStatus::WD_WD_MODIFIED)
{
extra = " (modified content)";
} else if status.contains(git2::SubmoduleStatus::WD_UNTRACKED) {
extra = " (untracked content)";
}
}
let (mut a, mut b, mut c) = (None, None, None);
if let Some(diff) = entry.head_to_index() {
a = diff.old_file().path();
b = diff.new_file().path();
}
if let Some(diff) = entry.index_to_workdir() {
a = a.or_else(|| diff.old_file().path());
b = b.or_else(|| diff.old_file().path());
c = diff.new_file().path();
}
output += &match (index_status, worktree_status) {
('R', 'R') => format!(
"RR {} {} {}{}\n",
a.unwrap().display(),
b.unwrap().display(),
c.unwrap().display(),
extra
),
('R', worktree_status) => format!(
"R{} {} {}{}\n",
worktree_status,
a.unwrap().display(),
b.unwrap().display(),
extra
),
(index_status, 'R') => format!(
"{}R {} {}{}\n",
index_status,
a.unwrap().display(),
c.unwrap().display(),
extra
),
(index_status, worktree_status) => {
format!(
"{}{} {}{}\n",
index_status,
worktree_status,
a.unwrap().display(),
extra
)
}
}
}
for entry in statuses
.iter()
.filter(|e| e.status() == git2::Status::WT_NEW)
{
output += &format!(
"?? {}\n",
entry
.index_to_workdir()
.unwrap()
.old_file()
.path()
.unwrap()
.display()
);
}
output
}