use anyhow::Result;
use dashmap::DashMap;
use std::path::{Path, PathBuf};
use std::sync::OnceLock;
use std::time::Duration;
use tokio::process::Command;
use super::status::Status;
const GIT_OPERATION_TIMEOUT_SECS: u64 = 180;
const GIT_DIFF_INDEX_ARGS: &[&str] = &["diff-index", "--quiet", "HEAD", "--"];
const GIT_REMOTE_ARGS: &[&str] = &["remote"];
const GIT_REV_PARSE_HEAD_ARGS: &[&str] = &["rev-parse", "--abbrev-ref", "HEAD"];
const GIT_FETCH_ARGS: &[&str] = &["fetch", "--quiet"];
const GIT_PUSH_ARGS: &[&str] = &["push"];
const GIT_CONFIG_GET_ARGS: &[&str] = &["config", "--get"];
const GIT_ADD_ARGS: &[&str] = &["add"];
const GIT_RESTORE_STAGED_ARGS: &[&str] = &["restore", "--staged"];
const GIT_STATUS_PORCELAIN_ARGS: &[&str] = &["status", "--porcelain"];
const GIT_COMMIT_ARGS: &[&str] = &["commit", "-m"];
const GIT_DIFF_CACHED_ARGS: &[&str] = &["diff", "--cached", "--quiet"];
const DETACHED_HEAD_BRANCH: &str = "HEAD";
const STATUS_NO_REMOTE: &str = "no remote";
const STATUS_DETACHED_HEAD: &str = "detached HEAD";
const STATUS_NO_UPSTREAM: &str = "no tracking";
const STATUS_SYNCED: &str = "up to date";
#[doc(hidden)]
pub async fn run_git(path: &Path, args: &[&str]) -> Result<(bool, String, String)> {
let timeout_duration = Duration::from_secs(GIT_OPERATION_TIMEOUT_SECS);
let result = tokio::time::timeout(
timeout_duration,
Command::new("git").args(args).current_dir(path).output(),
)
.await;
match result {
Ok(Ok(output)) => {
let stdout = String::from_utf8_lossy(&output.stdout);
let stdout_trimmed = stdout.trim();
let stdout_string = if stdout_trimmed.is_empty() {
String::new() } else {
stdout_trimmed.to_string()
};
let stderr = String::from_utf8_lossy(&output.stderr);
let stderr_trimmed = stderr.trim();
let stderr_string = if stderr_trimmed.is_empty() {
String::new() } else {
stderr_trimmed.to_string()
};
Ok((output.status.success(), stdout_string, stderr_string))
}
Ok(Err(e)) => Err(e.into()),
Err(_) => Err(anyhow::anyhow!(
"Git operation timed out after {} seconds",
GIT_OPERATION_TIMEOUT_SECS
)),
}
}
#[cfg(test)]
mod tests {
#[test]
fn test_string_allocation_optimization() {
let empty_bytes: Vec<u8> = vec![];
let whitespace_bytes: Vec<u8> = vec![b' ', b'\t', b'\n'];
let empty_str = String::from_utf8_lossy(&empty_bytes);
let empty_trimmed = empty_str.trim();
let empty_result = if empty_trimmed.is_empty() {
String::new()
} else {
empty_trimmed.to_string()
};
let whitespace_str = String::from_utf8_lossy(&whitespace_bytes);
let whitespace_trimmed = whitespace_str.trim();
let whitespace_result = if whitespace_trimmed.is_empty() {
String::new()
} else {
whitespace_trimmed.to_string()
};
assert_eq!(empty_result, "");
assert_eq!(whitespace_result, "");
let content_bytes: Vec<u8> = b" hello world ".to_vec();
let content_str = String::from_utf8_lossy(&content_bytes);
let content_trimmed = content_str.trim();
let content_result = if content_trimmed.is_empty() {
String::new()
} else {
content_trimmed.to_string()
};
assert_eq!(content_result, "hello world");
}
#[test]
fn test_repo_visibility_enum_basics() {
use super::RepoVisibility;
let public = RepoVisibility::Public;
let private = RepoVisibility::Private;
let unknown = RepoVisibility::Unknown;
assert_eq!(public, RepoVisibility::Public);
assert_eq!(private, RepoVisibility::Private);
assert_eq!(unknown, RepoVisibility::Unknown);
assert_ne!(public, private);
}
}
pub(crate) async fn get_git_config(path: &Path, key: &str) -> Result<Option<String>> {
let mut args = Vec::from(GIT_CONFIG_GET_ARGS);
args.push(key);
match run_git(path, &args).await {
Ok((true, value, _)) => {
if value.is_empty() {
Ok(None)
} else {
Ok(Some(value))
}
}
Ok((false, _, _)) => Ok(None), Err(e) => Err(e),
}
}
pub(crate) async fn set_git_config(path: &Path, key: &str, value: &str) -> Result<bool> {
let args = vec!["config", key, value];
match run_git(path, &args).await {
Ok((success, _, _)) => Ok(success),
Err(e) => Err(e),
}
}
fn is_rate_limit_error(error_msg: &str) -> bool {
let error_lower = error_msg.to_lowercase();
error_lower.contains("rate limit")
|| error_lower.contains("too many requests")
|| error_lower.contains("secondary rate limit")
|| (error_lower.contains("403") && error_lower.contains("github"))
}
#[derive(Clone)]
pub struct FetchResult {
pub has_uncommitted: bool,
pub current_branch: String,
pub ahead_count: u32,
pub upstream_exists: bool,
pub status: Status,
pub message: String,
}
pub async fn fetch_and_analyze(path: &Path, _force_push: bool) -> FetchResult {
use crate::core::clean_error_message;
let _ = run_git(path, &["update-index", "--refresh"]).await;
let has_uncommitted = match run_git(path, GIT_DIFF_INDEX_ARGS).await {
Ok((false, _, _)) => true,
Ok((true, _, _)) => false,
Err(_) => false,
};
let remotes = match run_git(path, GIT_REMOTE_ARGS).await {
Ok((true, output, _)) => output,
Ok((false, _, _)) | Err(_) => {
return FetchResult {
has_uncommitted,
current_branch: String::new(),
ahead_count: 0,
upstream_exists: false,
status: Status::NoRemote,
message: STATUS_NO_REMOTE.to_string(),
};
}
};
if remotes.trim().is_empty() {
return FetchResult {
has_uncommitted,
current_branch: String::new(),
ahead_count: 0,
upstream_exists: false,
status: Status::NoRemote,
message: STATUS_NO_REMOTE.to_string(),
};
}
let current_branch = match run_git(path, GIT_REV_PARSE_HEAD_ARGS).await {
Ok((true, branch, _)) => branch,
Ok((false, _, _)) | Err(_) => {
return FetchResult {
has_uncommitted,
current_branch: String::new(),
ahead_count: 0,
upstream_exists: false,
status: Status::Skip,
message: STATUS_DETACHED_HEAD.to_string(),
};
}
};
if current_branch == DETACHED_HEAD_BRANCH {
return FetchResult {
has_uncommitted,
current_branch: String::new(),
ahead_count: 0,
upstream_exists: false,
status: Status::Skip,
message: STATUS_DETACHED_HEAD.to_string(),
};
}
if let Err(e) = run_git(path, GIT_FETCH_ARGS).await {
let error_message = clean_error_message(&e.to_string());
let final_message = if is_rate_limit_error(&error_message) {
format!("⚠️ RATE LIMIT: {}", error_message)
} else {
error_message
};
return FetchResult {
has_uncommitted,
current_branch,
ahead_count: 0,
upstream_exists: false,
status: Status::Error,
message: final_message,
};
}
let upstream_check = run_git(path, &["rev-parse", "--abbrev-ref", "@{upstream}"]).await;
let upstream_exists = upstream_check.as_ref().is_ok_and(|result| result.0);
if !upstream_exists {
let status = Status::NoUpstream;
return FetchResult {
has_uncommitted,
current_branch,
ahead_count: 0,
upstream_exists: false,
status,
message: STATUS_NO_UPSTREAM.to_string(),
};
}
let ahead_check = run_git(path, &["rev-list", "--count", "HEAD", "^@{upstream}"]).await;
let ahead_count: u32 = match ahead_check {
Ok((true, count_str, _)) => count_str.trim().parse().unwrap_or(0),
_ => 0,
};
let behind_check = run_git(path, &["rev-list", "--count", "@{upstream}", "^HEAD"]).await;
let behind_count: u32 = match behind_check {
Ok((true, count_str, _)) => count_str.trim().parse().unwrap_or(0),
_ => 0,
};
if ahead_count > 0 && behind_count > 0 {
return FetchResult {
has_uncommitted,
current_branch,
ahead_count,
upstream_exists: true,
status: Status::Error,
message: format!(
"diverged: {} ahead, {} behind (pull required before push)",
ahead_count, behind_count
),
};
}
if ahead_count == 0 {
FetchResult {
has_uncommitted,
current_branch,
ahead_count: 0,
upstream_exists: true,
status: Status::Synced,
message: STATUS_SYNCED.to_string(),
}
} else {
FetchResult {
has_uncommitted,
current_branch,
ahead_count,
upstream_exists: true,
status: Status::Synced, message: format!("{} commits ahead", ahead_count),
}
}
}
pub async fn push_if_needed(path: &Path, fetch_result: &FetchResult, force_push: bool) -> (Status, String, bool) {
use crate::core::clean_error_message;
if fetch_result.status != Status::Synced && fetch_result.status != Status::NoUpstream {
return (fetch_result.status, fetch_result.message.clone(), fetch_result.has_uncommitted);
}
if !fetch_result.upstream_exists {
if force_push {
let remote_name = match run_git(path, GIT_REMOTE_ARGS).await {
Ok((true, remotes, _)) => {
remotes.lines().next().unwrap_or("origin").to_string()
}
_ => "origin".to_string(), };
let push_args = vec!["push", "-u", &remote_name, &fetch_result.current_branch];
match run_git(path, &push_args).await {
Ok((true, _, _)) => {
return (
Status::Pushed,
format!("set upstream ({}) & pushed", remote_name),
fetch_result.has_uncommitted,
);
}
Ok((false, _, stderr)) => {
let error_message = clean_error_message(&stderr);
return (Status::Error, error_message, fetch_result.has_uncommitted);
}
Err(e) => {
let error_message = clean_error_message(&e.to_string());
return (Status::Error, error_message, fetch_result.has_uncommitted);
}
}
} else {
return (Status::NoUpstream, STATUS_NO_UPSTREAM.to_string(), fetch_result.has_uncommitted);
}
}
if fetch_result.ahead_count == 0 {
return (Status::Synced, STATUS_SYNCED.to_string(), fetch_result.has_uncommitted);
}
match run_git(path, GIT_PUSH_ARGS).await {
Ok((true, _, _)) => {
let commits_word = if fetch_result.ahead_count == 1 {
"commit"
} else {
"commits"
};
(
Status::Pushed,
format!("{} {} pushed", fetch_result.ahead_count, commits_word),
fetch_result.has_uncommitted,
)
}
Ok((false, _, stderr)) => {
let error_message = clean_error_message(&stderr);
let final_message = if is_rate_limit_error(&error_message) {
format!("⚠️ RATE LIMIT: {}", error_message)
} else {
error_message
};
(Status::Error, final_message, fetch_result.has_uncommitted)
}
Err(e) => {
let error_message = clean_error_message(&e.to_string());
let final_message = if is_rate_limit_error(&error_message) {
format!("⚠️ RATE LIMIT: {}", error_message)
} else {
error_message
};
(Status::Error, final_message, fetch_result.has_uncommitted)
}
}
}
pub async fn stage_files(path: &Path, pattern: &str) -> Result<(bool, String, String)> {
let mut args = Vec::from(GIT_ADD_ARGS);
args.push(pattern);
run_git(path, &args).await
}
pub async fn unstage_files(path: &Path, pattern: &str) -> Result<(bool, String, String)> {
let mut args = Vec::from(GIT_RESTORE_STAGED_ARGS);
args.push(pattern);
run_git(path, &args).await
}
pub async fn get_staging_status(path: &Path) -> Result<(String, String)> {
match run_git(path, GIT_STATUS_PORCELAIN_ARGS).await {
Ok((_, stdout, stderr)) => Ok((stdout, stderr)),
Err(e) => Err(e),
}
}
pub async fn has_staged_changes(path: &Path) -> Result<bool> {
match run_git(path, GIT_DIFF_CACHED_ARGS).await {
Ok((success, _, _)) => Ok(!success), Err(e) => Err(e),
}
}
pub async fn commit_changes(
path: &Path,
message: &str,
allow_empty: bool,
) -> Result<(bool, String, String)> {
let mut args = Vec::from(GIT_COMMIT_ARGS);
args.push(message);
if allow_empty {
args.insert(1, "--allow-empty"); }
run_git(path, &args).await
}
pub async fn has_uncommitted_changes(path: &Path) -> bool {
let _ = run_git(path, &["update-index", "--refresh"]).await;
match run_git(path, GIT_DIFF_INDEX_ARGS).await {
Ok((false, _, _)) => true, Ok((true, _, _)) => false, Err(_) => false, }
}
pub async fn create_and_push_tag(path: &Path, tag_name: &str) -> (bool, String) {
let tag_result = run_git(path, &["tag", tag_name]).await;
if let Err(e) = tag_result {
return (false, format!("failed to create tag: {}", e));
}
let (success, _, stderr) = tag_result.unwrap();
if !success {
if stderr.contains("already exists") {
return (true, "tag already exists".to_string());
}
return (false, format!("failed to create tag: {}", stderr));
}
let push_result = run_git(path, &["push", "origin", tag_name]).await;
match push_result {
Ok((true, _, _)) => (true, format!("tagged & pushed {}", tag_name)),
Ok((false, _, stderr)) => {
(true, format!("tagged {} (push failed: {})", tag_name, stderr.lines().next().unwrap_or("unknown error")))
}
Err(e) => {
(true, format!("tagged {} (push failed: {})", tag_name, e))
}
}
}
#[derive(Clone)]
pub struct PullFetchResult {
pub has_uncommitted: bool,
pub behind_count: u32,
pub status: Status,
pub message: String,
}
pub async fn fetch_and_analyze_for_pull(path: &Path) -> PullFetchResult {
use crate::core::clean_error_message;
let _ = run_git(path, &["update-index", "--refresh"]).await;
let has_uncommitted = match run_git(path, GIT_DIFF_INDEX_ARGS).await {
Ok((false, _, _)) => true,
Ok((true, _, _)) => false,
Err(_) => false,
};
let remotes = match run_git(path, GIT_REMOTE_ARGS).await {
Ok((true, output, _)) => output,
Ok((false, _, _)) | Err(_) => {
return PullFetchResult {
has_uncommitted,
behind_count: 0,
status: Status::NoRemote,
message: STATUS_NO_REMOTE.to_string(),
};
}
};
if remotes.trim().is_empty() {
return PullFetchResult {
has_uncommitted,
behind_count: 0,
status: Status::NoRemote,
message: STATUS_NO_REMOTE.to_string(),
};
}
let current_branch = match run_git(path, GIT_REV_PARSE_HEAD_ARGS).await {
Ok((true, branch, _)) => branch,
Ok((false, _, _)) | Err(_) => {
return PullFetchResult {
has_uncommitted,
behind_count: 0,
status: Status::Skip,
message: STATUS_DETACHED_HEAD.to_string(),
};
}
};
if current_branch == DETACHED_HEAD_BRANCH {
return PullFetchResult {
has_uncommitted,
behind_count: 0,
status: Status::Skip,
message: STATUS_DETACHED_HEAD.to_string(),
};
}
if let Err(e) = run_git(path, GIT_FETCH_ARGS).await {
let error_message = clean_error_message(&e.to_string());
let final_message = if is_rate_limit_error(&error_message) {
format!("⚠️ RATE LIMIT: {}", error_message)
} else {
error_message
};
return PullFetchResult {
has_uncommitted,
behind_count: 0,
status: Status::Error,
message: final_message,
};
}
let upstream_check = run_git(path, &["rev-parse", "--abbrev-ref", "@{upstream}"]).await;
let upstream_exists = upstream_check.as_ref().is_ok_and(|result| result.0);
if !upstream_exists {
return PullFetchResult {
has_uncommitted,
behind_count: 0,
status: Status::NoUpstream,
message: STATUS_NO_UPSTREAM.to_string(),
};
}
let behind_check = run_git(path, &["rev-list", "--count", "@{upstream}", "^HEAD"]).await;
let behind_count: u32 = match behind_check {
Ok((true, count_str, _)) => count_str.trim().parse().unwrap_or(0),
_ => 0,
};
let ahead_check = run_git(path, &["rev-list", "--count", "HEAD", "^@{upstream}"]).await;
let ahead_count: u32 = match ahead_check {
Ok((true, count_str, _)) => count_str.trim().parse().unwrap_or(0),
_ => 0,
};
if ahead_count > 0 && behind_count > 0 {
return PullFetchResult {
has_uncommitted,
behind_count,
status: Status::PullError,
message: format!(
"diverged: {} ahead, {} behind (manual merge required)",
ahead_count, behind_count
),
};
}
if behind_count == 0 {
PullFetchResult {
has_uncommitted,
behind_count: 0,
status: Status::Synced,
message: STATUS_SYNCED.to_string(),
}
} else {
PullFetchResult {
has_uncommitted,
behind_count,
status: Status::Synced, message: format!("{} commits behind", behind_count),
}
}
}
pub async fn pull_if_needed(
path: &Path,
fetch_result: &PullFetchResult,
use_rebase: bool,
) -> (Status, String, bool) {
use crate::core::clean_error_message;
if fetch_result.status != Status::Synced {
return (
fetch_result.status,
fetch_result.message.clone(),
fetch_result.has_uncommitted,
);
}
if fetch_result.behind_count == 0 {
return (
Status::Synced,
STATUS_SYNCED.to_string(),
fetch_result.has_uncommitted,
);
}
let pull_args = if use_rebase {
vec!["pull", "--rebase", "--autostash"]
} else {
vec!["pull", "--ff-only"]
};
match run_git(path, &pull_args).await {
Ok((true, _, _)) => {
let commits_word = if fetch_result.behind_count == 1 {
"commit"
} else {
"commits"
};
(
Status::Pulled,
format!("{} {} pulled", fetch_result.behind_count, commits_word),
fetch_result.has_uncommitted,
)
}
Ok((false, _, stderr)) => {
let error_message = clean_error_message(&stderr);
let final_message = if error_message.to_lowercase().contains("conflict") {
format!("merge conflict: {}", error_message)
} else if error_message.to_lowercase().contains("would be overwritten") {
format!("uncommitted changes conflict: {}", error_message)
} else if is_rate_limit_error(&error_message) {
format!("⚠️ RATE LIMIT: {}", error_message)
} else {
error_message
};
(Status::PullError, final_message, fetch_result.has_uncommitted)
}
Err(e) => {
let error_message = clean_error_message(&e.to_string());
let final_message = if is_rate_limit_error(&error_message) {
format!("⚠️ RATE LIMIT: {}", error_message)
} else {
error_message
};
(Status::PullError, final_message, fetch_result.has_uncommitted)
}
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum RepoVisibility {
Public,
Private,
Unknown,
}
static VISIBILITY_CACHE: OnceLock<DashMap<PathBuf, RepoVisibility>> = OnceLock::new();
fn get_visibility_cache() -> &'static DashMap<PathBuf, RepoVisibility> {
VISIBILITY_CACHE.get_or_init(|| DashMap::new())
}
pub async fn get_repo_visibility(path: &Path) -> RepoVisibility {
let path_buf = path.to_path_buf();
let cache = get_visibility_cache();
if let Some(visibility) = cache.get(&path_buf) {
return *visibility;
}
let visibility = get_repo_visibility_uncached(path).await;
cache.insert(path_buf, visibility);
visibility
}
async fn get_repo_visibility_uncached(path: &Path) -> RepoVisibility {
let remote_url = match run_git(path, &["remote", "get-url", "origin"]).await {
Ok((true, url, _)) => url,
_ => return RepoVisibility::Unknown,
};
if !remote_url.contains("github.com") {
return RepoVisibility::Unknown;
}
let timeout_duration = Duration::from_secs(10);
let result = tokio::time::timeout(
timeout_duration,
Command::new("gh")
.args(["repo", "view", "--json", "isPrivate", "-q", ".isPrivate"])
.current_dir(path)
.output(),
)
.await;
match result {
Ok(Ok(output)) if output.status.success() => {
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
match stdout.as_str() {
"true" => RepoVisibility::Private,
"false" => RepoVisibility::Public,
_ => RepoVisibility::Unknown,
}
}
_ => RepoVisibility::Unknown, }
}