claude-code-status-line 1.2.2

A configurable status line for Claude Code with powerline arrows, context tracking, and quota monitoring
Documentation
use crate::types::GitInfo;
use std::process::Command;

fn get_repo_name(dir: &str) -> Option<String> {
    let output = Command::new("git")
        .args(["-C", dir, "remote", "get-url", "origin"])
        .output()
        .ok()?;

    if !output.status.success() {
        return None;
    }

    let url = String::from_utf8(output.stdout).ok()?;
    let url = url.trim();

    // Extract repo name from various URL formats:
    // https://github.com/user/repo.git -> repo
    // git@github.com:user/repo.git -> repo
    // https://github.com/user/repo -> repo
    let repo = url.rsplit('/').next()?.trim_end_matches(".git").to_string();

    if repo.is_empty() {
        None
    } else {
        Some(repo)
    }
}

pub fn get_git_info(dir: &str) -> Option<GitInfo> {
    let output = match Command::new("git")
        .args(["-C", dir, "status", "--porcelain", "-b"])
        .output()
    {
        Ok(o) => o,
        Err(e) => {
            if std::env::var("STATUSLINE_DEBUG").is_ok() {
                eprintln!("statusline warning: git not available: {}", e);
            }
            return None;
        }
    };

    if !output.status.success() {
        return None;
    }

    let stdout = String::from_utf8(output.stdout).ok()?;
    let lines: Vec<&str> = stdout.lines().collect();

    if lines.is_empty() {
        return None;
    }

    let branch_line = lines[0];
    let branch = if let Some(raw) = branch_line.strip_prefix("## ") {
        if let Some(idx) = raw.find("...") {
            raw[..idx].to_string()
        } else if let Some(stripped) = raw.strip_prefix("No commits yet on ") {
            stripped.to_string()
        } else if raw.starts_with("HEAD (no branch)") {
            "HEAD".to_string()
        } else {
            raw.to_string()
        }
    } else {
        return None;
    };

    let is_dirty = lines.len() > 1;
    let repo_name = get_repo_name(dir);

    // Get diff stats (lines added/removed)
    let (lines_added, lines_removed) = get_diff_stats(dir);

    Some(GitInfo {
        branch,
        is_dirty,
        repo_name,
        lines_added,
        lines_removed,
    })
}

fn get_diff_stats(dir: &str) -> (usize, usize) {
    let output = match Command::new("git")
        .args(["-C", dir, "diff", "--numstat"])
        .output()
    {
        Ok(o) => o,
        Err(_) => return (0, 0),
    };

    if !output.status.success() {
        return (0, 0);
    }

    let stdout = match String::from_utf8(output.stdout) {
        Ok(s) => s,
        Err(_) => return (0, 0),
    };

    let mut total_added = 0;
    let mut total_removed = 0;

    for line in stdout.lines() {
        let parts: Vec<&str> = line.split_whitespace().collect();
        if parts.len() >= 2 {
            if let Ok(added) = parts[0].parse::<usize>() {
                total_added += added;
            }
            if let Ok(removed) = parts[1].parse::<usize>() {
                total_removed += removed;
            }
        }
    }

    (total_added, total_removed)
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::fs;
    use tempfile::TempDir;

    fn create_test_repo() -> TempDir {
        let dir = TempDir::new().unwrap();
        let path = dir.path().to_str().unwrap();

        // Initialize git repo
        std::process::Command::new("git")
            .args(["init"])
            .current_dir(path)
            .output()
            .unwrap();

        // Configure git user for commits
        std::process::Command::new("git")
            .args(["config", "user.email", "test@example.com"])
            .current_dir(path)
            .output()
            .unwrap();

        std::process::Command::new("git")
            .args(["config", "user.name", "Test User"])
            .current_dir(path)
            .output()
            .unwrap();

        dir
    }

    #[test]
    fn test_get_repo_name_https_url() {
        let dir = create_test_repo();
        let path = dir.path().to_str().unwrap();

        // Add remote with HTTPS URL
        std::process::Command::new("git")
            .args([
                "remote",
                "add",
                "origin",
                "https://github.com/user/my-repo.git",
            ])
            .current_dir(path)
            .output()
            .unwrap();

        let result = get_repo_name(path);
        assert_eq!(result, Some("my-repo".to_string()));
    }

    #[test]
    fn test_get_repo_name_ssh_url() {
        let dir = create_test_repo();
        let path = dir.path().to_str().unwrap();

        // Add remote with SSH URL
        std::process::Command::new("git")
            .args(["remote", "add", "origin", "git@github.com:user/my-repo.git"])
            .current_dir(path)
            .output()
            .unwrap();

        let result = get_repo_name(path);
        assert_eq!(result, Some("my-repo".to_string()));
    }

    #[test]
    fn test_get_repo_name_without_git_extension() {
        let dir = create_test_repo();
        let path = dir.path().to_str().unwrap();

        // Add remote without .git extension
        std::process::Command::new("git")
            .args(["remote", "add", "origin", "https://github.com/user/my-repo"])
            .current_dir(path)
            .output()
            .unwrap();

        let result = get_repo_name(path);
        assert_eq!(result, Some("my-repo".to_string()));
    }

    #[test]
    fn test_get_repo_name_no_remote() {
        let dir = create_test_repo();
        let path = dir.path().to_str().unwrap();

        // No remote configured
        let result = get_repo_name(path);
        assert_eq!(result, None);
    }

    #[test]
    fn test_get_git_info_no_commits() {
        let dir = create_test_repo();
        let path = dir.path().to_str().unwrap();

        let result = get_git_info(path);
        // Modern git returns a result even without commits (initial branch)
        // The branch might be "master", "main", or "No commits yet on <branch>"
        if let Some(info) = result {
            assert!(!info.branch.is_empty());
            assert!(!info.is_dirty); // No changes yet
        }
        // Some git versions might return None, both are acceptable
    }

    #[test]
    fn test_get_git_info_with_commit() {
        let dir = create_test_repo();
        let path = dir.path().to_str().unwrap();

        // Create a file and commit
        fs::write(dir.path().join("test.txt"), "content").unwrap();
        std::process::Command::new("git")
            .args(["add", "test.txt"])
            .current_dir(path)
            .output()
            .unwrap();
        std::process::Command::new("git")
            .args(["commit", "-m", "Initial commit"])
            .current_dir(path)
            .output()
            .unwrap();

        let result = get_git_info(path).unwrap();
        assert_eq!(result.branch, "master");
        assert!(!result.is_dirty);
        assert_eq!(result.lines_added, 0);
        assert_eq!(result.lines_removed, 0);
    }

    #[test]
    fn test_get_git_info_dirty() {
        let dir = create_test_repo();
        let path = dir.path().to_str().unwrap();

        // Create and commit a file
        fs::write(dir.path().join("test.txt"), "content").unwrap();
        std::process::Command::new("git")
            .args(["add", "test.txt"])
            .current_dir(path)
            .output()
            .unwrap();
        std::process::Command::new("git")
            .args(["commit", "-m", "Initial commit"])
            .current_dir(path)
            .output()
            .unwrap();

        // Modify the file (dirty working directory)
        fs::write(dir.path().join("test.txt"), "modified content").unwrap();

        let result = get_git_info(path).unwrap();
        assert!(result.is_dirty);
    }

    #[test]
    fn test_get_git_info_with_diff_stats() {
        let dir = create_test_repo();
        let path = dir.path().to_str().unwrap();

        // Create and commit a file
        fs::write(dir.path().join("test.txt"), "line1\nline2\n").unwrap();
        std::process::Command::new("git")
            .args(["add", "test.txt"])
            .current_dir(path)
            .output()
            .unwrap();
        std::process::Command::new("git")
            .args(["commit", "-m", "Initial commit"])
            .current_dir(path)
            .output()
            .unwrap();

        // Modify file: add lines, remove lines
        fs::write(dir.path().join("test.txt"), "line1\nline3\nline4\n").unwrap();

        let result = get_git_info(path).unwrap();
        assert!(result.lines_added > 0 || result.lines_removed > 0);
    }

    #[test]
    fn test_get_git_info_not_a_repo() {
        let dir = TempDir::new().unwrap();
        let path = dir.path().to_str().unwrap();

        // Not a git repository
        let result = get_git_info(path);
        assert_eq!(result, None);
    }

    #[test]
    fn test_get_diff_stats_no_changes() {
        let dir = create_test_repo();
        let path = dir.path().to_str().unwrap();

        // Create and commit a file
        fs::write(dir.path().join("test.txt"), "content").unwrap();
        std::process::Command::new("git")
            .args(["add", "test.txt"])
            .current_dir(path)
            .output()
            .unwrap();
        std::process::Command::new("git")
            .args(["commit", "-m", "Initial commit"])
            .current_dir(path)
            .output()
            .unwrap();

        let (added, removed) = get_diff_stats(path);
        assert_eq!(added, 0);
        assert_eq!(removed, 0);
    }
}