use crate::get_env; use git2::{DiffOptions, Error, ErrorCode, Repository, Status, StatusOptions, StatusShow};
use serde::{Deserialize, Serialize};
use std::{
env,
fmt::Write as _,
fs,
path::PathBuf,
time::{SystemTime, UNIX_EPOCH},
};
#[must_use]
pub fn unix_timestamp() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |d| d.as_secs())
}
pub const ACTION_REBASE: &str = "rebase";
pub const ACTION_AM: &str = "am";
pub const ACTION_AM_REBASE: &str = "am/rebase";
pub const ACTION_REBASE_I: &str = "rebase-i";
pub const ACTION_REBASE_M: &str = "rebase-m";
pub const ACTION_MERGE: &str = "merge";
pub const ACTION_BISECT: &str = "bisect";
pub const ACTION_CHERRY_SEQ: &str = "cherry-seq";
pub const ACTION_CHERRY: &str = "cherry";
pub const ACTION_CHERRY_OR_REVERT: &str = "cherry-or-revert";
pub const NO_BRANCH: &str = "(no branch)";
#[derive(Default)]
struct StatusCounts {
conflicted: u32,
added_modified: u32,
modified_modified: u32,
modified: u32,
deleted: u32,
renamed: u32,
typechanged: u32,
added: u32,
untracked: u32,
ignored: u32,
fallback: u32,
}
impl StatusCounts {
const fn increment(&mut self, status: Status) {
if status.contains(Status::CONFLICTED) {
self.conflicted += 1;
} else if status.contains(Status::INDEX_RENAMED) || status.contains(Status::WT_RENAMED) {
self.renamed += 1;
} else if status.contains(Status::INDEX_NEW) && status.contains(Status::WT_MODIFIED) {
self.added_modified += 1;
} else if status.contains(Status::INDEX_MODIFIED) && status.contains(Status::WT_MODIFIED) {
self.modified_modified += 1;
} else if status.contains(Status::INDEX_MODIFIED) || status.contains(Status::WT_MODIFIED) {
self.modified += 1;
} else if status.contains(Status::INDEX_DELETED) || status.contains(Status::WT_DELETED) {
self.deleted += 1;
} else if status.contains(Status::INDEX_TYPECHANGE)
|| status.contains(Status::WT_TYPECHANGE)
{
self.typechanged += 1;
} else if status.contains(Status::INDEX_NEW) {
self.added += 1;
} else if status.contains(Status::WT_NEW) {
self.untracked += 1;
} else if status.contains(Status::IGNORED) {
self.ignored += 1;
} else {
self.fallback += 1;
}
}
}
#[derive(Serialize, Deserialize, Debug, Default, Clone)]
pub struct Prompt {
pub action: String,
pub branch: String,
pub remote: Vec<String>,
pub staged: bool,
pub status: String,
pub u_name: String,
pub auth_failed: bool,
#[serde(default)]
pub fetch_failed: bool,
}
#[derive(Debug, Default, Eq, PartialEq, Clone, Copy)]
pub enum FetchStatus {
#[default]
Ok,
AuthFailed,
Unreachable,
}
impl FetchStatus {
#[must_use]
pub const fn as_cache_value(self) -> &'static str {
match self {
Self::Ok => "0",
Self::AuthFailed => "1",
Self::Unreachable => "2",
}
}
#[must_use]
pub fn from_cache_value(value: &str) -> Self {
match value.trim() {
"1" => Self::AuthFailed,
"2" => Self::Unreachable,
_ => Self::Ok,
}
}
}
#[must_use]
pub fn build_prompt_fast(repo: &Repository) -> Prompt {
let branch = match repo.head() {
Ok(head) => head
.shorthand()
.map_or_else(|_| NO_BRANCH.to_owned(), str::to_owned),
Err(error) if error.code() == ErrorCode::UnbornBranch => repo
.find_reference("HEAD")
.ok()
.and_then(|head| {
head.symbolic_target()
.ok()
.flatten()
.and_then(|target| target.strip_prefix("refs/heads/"))
.map(str::to_owned)
})
.unwrap_or_else(|| NO_BRANCH.to_owned()),
Err(_) => NO_BRANCH.to_owned(),
};
let mut prompt = Prompt {
branch,
..Prompt::default()
};
if let Ok(config) = repo.config() {
prompt.u_name = config
.get_string("user.name")
.unwrap_or_else(|_| String::new());
}
let fetch_status = read_fetch_status(repo);
prompt.auth_failed = fetch_status == FetchStatus::AuthFailed;
prompt.fetch_failed = fetch_status == FetchStatus::Unreachable;
prompt.remote = remote_markers(repo);
if let Some(action) = get_action(repo) {
prompt.action = action;
}
if let Ok(staged) = is_staged(repo) {
prompt.staged = staged;
}
prompt
}
#[must_use]
pub fn remote_markers(repo: &Repository) -> Vec<String> {
let (ahead, behind) = is_ahead_behind_remote(repo);
let mut markers = Vec::with_capacity(2);
if behind > 0 {
let mut s = String::with_capacity(8);
let _ = write!(s, "{}{}", get_env("SLICK_PROMPT_GIT_REMOTE_BEHIND"), behind);
markers.push(s);
}
if ahead > 0 {
let mut s = String::with_capacity(8);
let _ = write!(s, "{}{}", get_env("SLICK_PROMPT_GIT_REMOTE_AHEAD"), ahead);
markers.push(s);
}
markers
}
pub fn get_status(repo: &Repository) -> Result<String, Error> {
let mut status: Vec<String> = Vec::with_capacity(8);
let mut status_opt = StatusOptions::new();
status_opt
.show(StatusShow::IndexAndWorkdir)
.include_untracked(true)
.recurse_untracked_dirs(true)
.include_unmodified(false)
.renames_head_to_index(true)
.renames_index_to_workdir(true)
.no_refresh(false);
let statuses = repo.statuses(Some(&mut status_opt))?;
if !statuses.is_empty() {
let mut counts = StatusCounts::default();
for entry in statuses.iter() {
counts.increment(entry.status());
}
for (label, count) in [
("UU", counts.conflicted),
("AM", counts.added_modified),
("MM", counts.modified_modified),
("M", counts.modified),
("D", counts.deleted),
("R", counts.renamed),
("T", counts.typechanged),
("A", counts.added),
("??", counts.untracked),
("!", counts.ignored),
("X", counts.fallback),
] {
if count > 0 {
status.push(format!("{label} {count}"));
}
}
}
Ok(status.join(" "))
}
#[must_use]
pub fn get_auth_cache_path(repo: &Repository) -> Option<PathBuf> {
let repo_path = repo
.workdir()
.and_then(|p| p.canonicalize().ok())?
.to_str()?
.to_string();
let cache_dir = env::var("SLICK_TEST_AUTH_CACHE_DIR")
.or_else(|_| env::var("XDG_CACHE_HOME"))
.or_else(|_| env::var("HOME").map(|h| format!("{h}/.cache")))
.ok()?;
let hash = repo_path.bytes().fold(0u64, |acc, b| {
acc.wrapping_mul(31).wrapping_add(u64::from(b))
});
let cache_path = PathBuf::from(cache_dir).join("slick");
Some(cache_path.join(format!("auth_{hash:x}")))
}
#[must_use]
pub fn read_fetch_status(repo: &Repository) -> FetchStatus {
if let Some(cache_path) = get_auth_cache_path(repo)
&& let Ok(content) = fs::read_to_string(&cache_path)
&& let Some((ts_str, status)) = content.split_once(':')
&& let Ok(cached_time) = ts_str.parse::<u64>()
{
let now = unix_timestamp();
if now.saturating_sub(cached_time) < 300 {
return FetchStatus::from_cache_value(status);
}
}
FetchStatus::Ok
}
#[must_use]
pub fn read_auth_status(repo: &Repository) -> bool {
read_fetch_status(repo) == FetchStatus::AuthFailed
}
#[must_use]
pub fn get_action(repo: &Repository) -> Option<String> {
let gitdir = repo.path();
for tmp in &[
gitdir.join("rebase-apply"),
gitdir.join("rebase"),
gitdir.join("..").join(".dotest"),
] {
if tmp.join("rebasing").exists() {
return Some(ACTION_REBASE.to_string());
}
if tmp.join("applying").exists() {
return Some(ACTION_AM.to_string());
}
if tmp.exists() {
return Some(ACTION_AM_REBASE.to_string());
}
}
for tmp in &[
gitdir.join("rebase-merge").join("interactive"),
gitdir.join(".dotest-merge").join("interactive"),
] {
if tmp.exists() {
return Some(ACTION_REBASE_I.to_string());
}
}
for tmp in &[gitdir.join("rebase-merge"), gitdir.join(".dotest-merge")] {
if tmp.exists() {
return Some(ACTION_REBASE_M.to_string());
}
}
if gitdir.join("MERGE_HEAD").exists() {
return Some(ACTION_MERGE.to_string());
}
if gitdir.join("BISECT_LOG").exists() {
return Some(ACTION_BISECT.to_string());
}
if gitdir.join("CHERRY_PICK_HEAD").exists() {
if gitdir.join("sequencer").exists() {
return Some(ACTION_CHERRY_SEQ.to_string());
}
return Some(ACTION_CHERRY.to_string());
}
if gitdir.join("sequencer").exists() {
return Some(ACTION_CHERRY_OR_REVERT.to_string());
}
None
}
#[must_use]
pub fn is_ahead_behind_remote(repo: &Repository) -> (usize, usize) {
if let Ok(head) = repo.revparse_single("HEAD") {
let head = head.id();
if let Ok((upstream, _)) = repo.revparse_ext("@{u}") {
return match repo.graph_ahead_behind(head, upstream.id()) {
Ok((commits_ahead, commits_behind)) => (commits_ahead, commits_behind),
Err(_) => (0, 0),
};
}
}
(0, 0)
}
pub fn is_staged(repo: &Repository) -> Result<bool, Error> {
let mut opts = DiffOptions::new();
let tree = match repo.head() {
Ok(head) => Some(head.peel_to_tree()?),
Err(error) if error.code() == ErrorCode::UnbornBranch => None,
Err(error) => return Err(error),
};
let diff = repo.diff_tree_to_index(tree.as_ref(), None, Some(&mut opts))?;
Ok(diff.deltas().len() > 0)
}