slick 0.14.1

async ZSH prompt
Documentation
use crate::get_env;
use git2::{DiffOptions, Error, ObjectType, Repository, StatusOptions, StatusShow};
use serde::{Deserialize, Serialize};
use std::{
    collections::BTreeMap,
    env, fs,
    path::PathBuf,
    process::Command,
    str,
    time::{SystemTime, UNIX_EPOCH},
};

#[derive(Serialize, Deserialize, Debug, Default)]
struct Prompt {
    action: String,
    branch: String,
    remote: Vec<String>,
    staged: bool,
    status: String,
    u_name: String,
    auth_failed: bool,
}

pub fn render() {
    if let Ok(path) = env::current_dir()
        && let Ok(repo) = Repository::discover(path)
    {
        build_prompt(&repo);
    }
}

fn build_prompt(repo: &Repository) {
    let mut prompt = Prompt::default();

    // ===== SYNCHRONOUS PART (instant display) =====
    // get user.name
    if let Ok(config) = repo.config() {
        prompt.u_name = config
            .get_string("user.name")
            .unwrap_or_else(|_| String::new());
    }

    // get branch
    if let Ok(head) = repo.head() {
        prompt.branch = head.shorthand().unwrap_or("(no branch)").to_string();
    } else {
        prompt.branch = "(no branch)".into();
    }

    // Check for cached auth status (synchronous, fast)
    prompt.auth_failed = read_auth_status(repo);

    // git fetch and auth check (truly async, fire-and-forget)
    if get_env("SLICK_PROMPT_GIT_FETCH") != "0" {
        let cache_path = get_auth_cache_path(repo);

        // Git fetch (detached) - also check for auth errors
        if let Some(cache) = cache_path {
            let cache_str = cache.to_string_lossy().to_string();

            // Create cache directory
            if let Some(parent) = cache.parent() {
                let _ = fs::create_dir_all(parent);
            }

            let _ = Command::new("sh")
                .arg("-c")
                .arg(format!(
                    r#"(
                        stderr=$(timeout 5 git -c gc.auto=0 fetch --quiet --no-tags --no-recurse-submodules 2>&1)
                        exit_code=$?
                        if [ $exit_code -eq 0 ]; then
                            echo "$(date +%s):0" > {}
                        elif [ $exit_code -eq 124 ]; then
                            # Timeout - likely auth issue
                            echo "$(date +%s):1" > {}
                        elif echo "$stderr" | grep -qiE "(permission denied|authentication failed|could not read|repository not found|access denied)"; then
                            echo "$(date +%s):1" > {}
                        fi
                    ) >/dev/null 2>&1 &"#,
                    cache_str, cache_str, cache_str
                ))
                .env("GIT_TERMINAL_PROMPT", "0")
                .env("GIT_SSH_COMMAND", "ssh -o BatchMode=yes -o ControlMaster=no")
                .env("GIT_ASKPASS", "true")
                .spawn();
        } else {
            // Fallback to simple fetch if no cache path
            let _ = Command::new("sh")
                .arg("-c")
                .arg("(timeout 5 git -c gc.auto=0 fetch --quiet --no-tags --no-recurse-submodules >/dev/null 2>&1) &")
                .env("GIT_TERMINAL_PROMPT", "0")
                .env("GIT_SSH_COMMAND", "ssh -o BatchMode=yes -o ControlMaster=no")
                .env("GIT_ASKPASS", "true")
                .spawn();
        }
    }

    // ===== SYNCHRONOUS GIT STATUS (for real-time repo state) =====
    // git remote
    let (ahead, behind) = is_ahead_behind_remote(repo);
    if behind > 0 {
        prompt.remote.push(format!(
            "{}{}",
            get_env("SLICK_PROMPT_GIT_REMOTE_BEHIND"),
            behind
        ));
    }
    if ahead > 0 {
        prompt.remote.push(format!(
            "{}{}",
            get_env("SLICK_PROMPT_GIT_REMOTE_AHEAD"),
            ahead
        ));
    }

    // git action
    if let Some(action) = get_action(repo) {
        prompt.action = action;
    }

    // git status
    if let Ok(status) = get_status(repo) {
        prompt.status = status;
    }

    // git staged
    if let Ok(staged) = is_staged(repo) {
        prompt.staged = staged;
    }

    // return prompt
    if let Ok(serialized) = serde_json::to_string(&prompt) {
        println!("{serialized}");
    }
}

fn get_status(repo: &Repository) -> Result<String, Error> {
    let mut status: Vec<String> = Vec::new();
    let mut status_opt = StatusOptions::new();
    status_opt
        .show(StatusShow::IndexAndWorkdir)
        .include_untracked(true)
        .include_unmodified(false)
        .no_refresh(false); // Keep false to get real-time status

    let statuses = repo.statuses(Some(&mut status_opt))?;
    if !statuses.is_empty() {
        let mut map: BTreeMap<&str, u32> = BTreeMap::new();
        for entry in statuses.iter() {
            // println!("{:#?}, {:#?}", entry.path(), entry.status());
            let status = match entry.status() {
                s if s.contains(git2::Status::INDEX_NEW)
                    && s.contains(git2::Status::WT_MODIFIED) =>
                {
                    "AM"
                }
                s if s.contains(git2::Status::INDEX_MODIFIED)
                    && s.contains(git2::Status::WT_MODIFIED) =>
                {
                    "MM"
                }
                s if s.contains(git2::Status::INDEX_MODIFIED)
                    || s.contains(git2::Status::WT_MODIFIED) =>
                {
                    "M"
                }
                s if s.contains(git2::Status::INDEX_DELETED)
                    || s.contains(git2::Status::WT_DELETED) =>
                {
                    "D"
                }
                s if s.contains(git2::Status::INDEX_RENAMED)
                    || s.contains(git2::Status::WT_RENAMED) =>
                {
                    "R"
                }
                s if s.contains(git2::Status::INDEX_TYPECHANGE)
                    || s.contains(git2::Status::WT_TYPECHANGE) =>
                {
                    "T"
                }
                s if s.contains(git2::Status::INDEX_NEW) => "A",
                s if s.contains(git2::Status::WT_NEW) => "??",
                s if s.contains(git2::Status::CONFLICTED) => "UU",
                s if s.contains(git2::Status::IGNORED) => "!",
                _ => "X",
            };

            *map.entry(status).or_insert(0) += 1;
        }
        for (k, v) in &map {
            status.push(format!("{k} {v}"));
        }
    }
    Ok(status.join(" "))
}

fn get_auth_cache_path(repo: &Repository) -> Option<PathBuf> {
    let repo_path = repo.path().to_str()?;
    let cache_dir = env::var("XDG_CACHE_HOME")
        .or_else(|_| env::var("HOME").map(|h| format!("{}/.cache", h)))
        .ok()?;

    // Create a hash of the repo path for the cache filename
    let hash = repo_path
        .bytes()
        .fold(0u64, |acc, b| acc.wrapping_mul(31).wrapping_add(b as u64));

    let cache_path = PathBuf::from(cache_dir).join("slick");
    Some(cache_path.join(format!("auth_{:x}", hash)))
}

fn read_auth_status(repo: &Repository) -> bool {
    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 = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .unwrap()
            .as_secs();

        // Cache valid for 5 minutes
        if now - cached_time < 300 {
            return status.trim() == "1";
        }
    }
    false
}

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("rebase".to_string());
        }
        if tmp.join("applying").exists() {
            return Some("am".to_string());
        }
        if tmp.exists() {
            return Some("am/rebase".to_string());
        }
    }

    for tmp in &[
        gitdir.join("rebase-merge").join("interactive"),
        gitdir.join(".dotest-merge").join("interactive"),
    ] {
        if tmp.exists() {
            return Some("rebase-i".to_string());
        }
    }

    for tmp in &[gitdir.join("rebase-merge"), gitdir.join(".dotest-merge")] {
        if tmp.exists() {
            return Some("rebase-m".to_string());
        }
    }

    if gitdir.join("MERGE_HEAD").exists() {
        return Some("merge".to_string());
    }

    if gitdir.join("BISECT_LOG").exists() {
        return Some("bisect".to_string());
    }

    if gitdir.join("CHERRY_PICK_HEAD").exists() {
        if gitdir.join("sequencer").exists() {
            return Some("cherry-seq".to_string());
        }
        return Some("cherry".to_string());
    }

    if gitdir.join("sequencer").exists() {
        return Some("cherry-or-revert".to_string());
    }

    None
}

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)
}

fn is_staged(repo: &Repository) -> Result<bool, Error> {
    let mut opts = DiffOptions::new();
    let obj = repo.head()?;
    let tree = obj.peel(ObjectType::Tree)?;
    let diff = repo.diff_tree_to_index(tree.as_tree(), None, Some(&mut opts))?;
    let stats = diff.stats()?;
    if stats.files_changed() > 0 || stats.insertions() > 0 || stats.deletions() > 0 {
        return Ok(true);
    }
    Ok(false)
    /*
     *  if ! git diff --cached --quiet; then echo staged; fi
     *
     * let format = git2::DiffStatsFormat::NUMBER;
     * let buf = stats.to_buf(format, 80).unwrap();
     * println!("diff: {}", str::from_utf8(&*buf).unwrap());
     */
}