Multi-LLM Provider Support: - Add llm-core crate with LlmProvider trait abstraction - Implement Anthropic Claude API client with streaming - Implement OpenAI API client with streaming - Add token counting with SimpleTokenCounter and ClaudeTokenCounter - Add retry logic with exponential backoff and jitter Borderless TUI Redesign: - Rewrite theme system with terminal capability detection (Full/Unicode256/Basic) - Add provider tabs component with keybind switching [1]/[2]/[3] - Implement vim-modal input (Normal/Insert/Visual/Command modes) - Redesign chat panel with timestamps and streaming indicators - Add multi-provider status bar with cost tracking - Add Nerd Font icons with graceful ASCII fallbacks - Add syntax highlighting (syntect) and markdown rendering (pulldown-cmark) Advanced Agent Features: - Add system prompt builder with configurable components - Enhance subagent orchestration with parallel execution - Add git integration module for safe command detection - Add streaming tool results via channels - Expand tool set: AskUserQuestion, TodoWrite, LS, MultiEdit, BashOutput, KillShell - Add WebSearch with provider abstraction Plugin System Enhancement: - Add full agent definition parsing from YAML frontmatter - Add skill system with progressive disclosure - Wire plugin hooks into HookManager 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
558 lines
17 KiB
Rust
558 lines
17 KiB
Rust
//! Git integration module for detecting repository state and validating git commands.
|
|
//!
|
|
//! This module provides functionality to:
|
|
//! - Detect if the current directory is a git repository
|
|
//! - Capture git repository state (branch, status, uncommitted changes)
|
|
//! - Validate git commands for safety (read-only vs destructive operations)
|
|
|
|
use color_eyre::eyre::Result;
|
|
use std::path::Path;
|
|
use std::process::Command;
|
|
|
|
/// Status of a file in the git working tree
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub enum GitFileStatus {
|
|
/// File has been modified
|
|
Modified { path: String },
|
|
/// File has been added (staged)
|
|
Added { path: String },
|
|
/// File has been deleted
|
|
Deleted { path: String },
|
|
/// File has been renamed
|
|
Renamed { from: String, to: String },
|
|
/// File is untracked
|
|
Untracked { path: String },
|
|
}
|
|
|
|
impl GitFileStatus {
|
|
/// Get the primary path associated with this status
|
|
pub fn path(&self) -> &str {
|
|
match self {
|
|
Self::Modified { path } => path,
|
|
Self::Added { path } => path,
|
|
Self::Deleted { path } => path,
|
|
Self::Renamed { to, .. } => to,
|
|
Self::Untracked { path } => path,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Complete state of a git repository
|
|
#[derive(Debug, Clone)]
|
|
pub struct GitState {
|
|
/// Whether the current directory is in a git repository
|
|
pub is_git_repo: bool,
|
|
/// Current branch name (None if not in a repo or detached HEAD)
|
|
pub current_branch: Option<String>,
|
|
/// Main branch name (main/master, None if not detected)
|
|
pub main_branch: Option<String>,
|
|
/// Status of files in the working tree
|
|
pub status: Vec<GitFileStatus>,
|
|
/// Whether there are any uncommitted changes
|
|
pub has_uncommitted_changes: bool,
|
|
/// Remote URL for the repository (None if no remote configured)
|
|
pub remote_url: Option<String>,
|
|
}
|
|
|
|
impl GitState {
|
|
/// Create a default GitState for non-git directories
|
|
pub fn not_a_repo() -> Self {
|
|
Self {
|
|
is_git_repo: false,
|
|
current_branch: None,
|
|
main_branch: None,
|
|
status: Vec::new(),
|
|
has_uncommitted_changes: false,
|
|
remote_url: None,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Detect the current git repository state
|
|
///
|
|
/// This function runs various git commands to gather information about the repository.
|
|
/// If git is not available or the directory is not a git repo, returns a default state.
|
|
pub fn detect_git_state(working_dir: &Path) -> Result<GitState> {
|
|
// Check if this is a git repository
|
|
let is_repo = Command::new("git")
|
|
.arg("rev-parse")
|
|
.arg("--git-dir")
|
|
.current_dir(working_dir)
|
|
.output()
|
|
.map(|output| output.status.success())
|
|
.unwrap_or(false);
|
|
|
|
if !is_repo {
|
|
return Ok(GitState::not_a_repo());
|
|
}
|
|
|
|
// Get current branch
|
|
let current_branch = get_current_branch(working_dir)?;
|
|
|
|
// Detect main branch (try main first, then master)
|
|
let main_branch = detect_main_branch(working_dir)?;
|
|
|
|
// Get file status
|
|
let status = get_git_status(working_dir)?;
|
|
|
|
// Check if there are uncommitted changes
|
|
let has_uncommitted_changes = !status.is_empty();
|
|
|
|
// Get remote URL
|
|
let remote_url = get_remote_url(working_dir)?;
|
|
|
|
Ok(GitState {
|
|
is_git_repo: true,
|
|
current_branch,
|
|
main_branch,
|
|
status,
|
|
has_uncommitted_changes,
|
|
remote_url,
|
|
})
|
|
}
|
|
|
|
/// Get the current branch name
|
|
fn get_current_branch(working_dir: &Path) -> Result<Option<String>> {
|
|
let output = Command::new("git")
|
|
.arg("rev-parse")
|
|
.arg("--abbrev-ref")
|
|
.arg("HEAD")
|
|
.current_dir(working_dir)
|
|
.output()?;
|
|
|
|
if !output.status.success() {
|
|
return Ok(None);
|
|
}
|
|
|
|
let branch = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
|
|
|
// "HEAD" means detached HEAD state
|
|
if branch == "HEAD" {
|
|
Ok(None)
|
|
} else {
|
|
Ok(Some(branch))
|
|
}
|
|
}
|
|
|
|
/// Detect the main branch (main or master)
|
|
fn detect_main_branch(working_dir: &Path) -> Result<Option<String>> {
|
|
// Try to get all branches
|
|
let output = Command::new("git")
|
|
.arg("branch")
|
|
.arg("-a")
|
|
.current_dir(working_dir)
|
|
.output()?;
|
|
|
|
if !output.status.success() {
|
|
return Ok(None);
|
|
}
|
|
|
|
let branches = String::from_utf8_lossy(&output.stdout);
|
|
|
|
// Check for main branch first (modern convention)
|
|
if branches.lines().any(|line| {
|
|
let trimmed = line.trim_start_matches('*').trim();
|
|
trimmed == "main" || trimmed.ends_with("/main")
|
|
}) {
|
|
return Ok(Some("main".to_string()));
|
|
}
|
|
|
|
// Fall back to master
|
|
if branches.lines().any(|line| {
|
|
let trimmed = line.trim_start_matches('*').trim();
|
|
trimmed == "master" || trimmed.ends_with("/master")
|
|
}) {
|
|
return Ok(Some("master".to_string()));
|
|
}
|
|
|
|
Ok(None)
|
|
}
|
|
|
|
/// Get the git status for all files
|
|
fn get_git_status(working_dir: &Path) -> Result<Vec<GitFileStatus>> {
|
|
let output = Command::new("git")
|
|
.arg("status")
|
|
.arg("--porcelain")
|
|
.arg("-z") // Null-terminated for better parsing
|
|
.current_dir(working_dir)
|
|
.output()?;
|
|
|
|
if !output.status.success() {
|
|
return Ok(Vec::new());
|
|
}
|
|
|
|
let status_text = String::from_utf8_lossy(&output.stdout);
|
|
let mut statuses = Vec::new();
|
|
|
|
// Parse porcelain format with null termination
|
|
// Format: XY filename\0 (where X is staged status, Y is unstaged status)
|
|
for entry in status_text.split('\0').filter(|s| !s.is_empty()) {
|
|
if entry.len() < 3 {
|
|
continue;
|
|
}
|
|
|
|
let status_code = &entry[0..2];
|
|
let path = entry[3..].to_string();
|
|
|
|
// Parse status codes
|
|
match status_code {
|
|
"M " | " M" | "MM" => {
|
|
statuses.push(GitFileStatus::Modified { path });
|
|
}
|
|
"A " | " A" | "AM" => {
|
|
statuses.push(GitFileStatus::Added { path });
|
|
}
|
|
"D " | " D" | "AD" => {
|
|
statuses.push(GitFileStatus::Deleted { path });
|
|
}
|
|
"??" => {
|
|
statuses.push(GitFileStatus::Untracked { path });
|
|
}
|
|
s if s.starts_with('R') => {
|
|
// Renamed files have format "R old_name -> new_name"
|
|
if let Some((from, to)) = path.split_once(" -> ") {
|
|
statuses.push(GitFileStatus::Renamed {
|
|
from: from.to_string(),
|
|
to: to.to_string(),
|
|
});
|
|
} else {
|
|
// Fallback if parsing fails
|
|
statuses.push(GitFileStatus::Modified { path });
|
|
}
|
|
}
|
|
_ => {
|
|
// Unknown status code, treat as modified
|
|
statuses.push(GitFileStatus::Modified { path });
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(statuses)
|
|
}
|
|
|
|
/// Get the remote URL for the repository
|
|
fn get_remote_url(working_dir: &Path) -> Result<Option<String>> {
|
|
let output = Command::new("git")
|
|
.arg("remote")
|
|
.arg("get-url")
|
|
.arg("origin")
|
|
.current_dir(working_dir)
|
|
.output()?;
|
|
|
|
if !output.status.success() {
|
|
return Ok(None);
|
|
}
|
|
|
|
let url = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
|
|
|
if url.is_empty() {
|
|
Ok(None)
|
|
} else {
|
|
Ok(Some(url))
|
|
}
|
|
}
|
|
|
|
/// Check if a git command is safe (read-only)
|
|
///
|
|
/// Safe commands include:
|
|
/// - status, log, show, diff, branch (without -D)
|
|
/// - remote (without add/remove)
|
|
/// - config --get
|
|
/// - rev-parse, ls-files, ls-tree
|
|
pub fn is_safe_git_command(command: &str) -> bool {
|
|
let parts: Vec<&str> = command.split_whitespace().collect();
|
|
|
|
if parts.is_empty() || parts[0] != "git" {
|
|
return false;
|
|
}
|
|
|
|
if parts.len() < 2 {
|
|
return false;
|
|
}
|
|
|
|
let subcommand = parts[1];
|
|
|
|
// List of read-only git commands
|
|
match subcommand {
|
|
"status" | "log" | "show" | "diff" | "blame" | "reflog" => true,
|
|
"ls-files" | "ls-tree" | "ls-remote" => true,
|
|
"rev-parse" | "rev-list" => true,
|
|
"describe" | "tag" if !command.contains("-d") && !command.contains("--delete") => true,
|
|
"branch" if !command.contains("-D") && !command.contains("-d") && !command.contains("-m") => true,
|
|
"remote" if command.contains("get-url") || command.contains("-v") || command.contains("show") => true,
|
|
"config" if command.contains("--get") || command.contains("--list") => true,
|
|
"grep" | "shortlog" | "whatchanged" => true,
|
|
"fetch" if !command.contains("--prune") => true,
|
|
_ => false,
|
|
}
|
|
}
|
|
|
|
/// Check if a git command is destructive
|
|
///
|
|
/// Returns (is_destructive, warning_message) tuple.
|
|
/// Destructive commands include:
|
|
/// - push --force, reset --hard, clean -fd
|
|
/// - rebase, amend, filter-branch
|
|
/// - branch -D, tag -d
|
|
pub fn is_destructive_git_command(command: &str) -> (bool, &'static str) {
|
|
let cmd_lower = command.to_lowercase();
|
|
|
|
// Check for force push
|
|
if cmd_lower.contains("push") && (cmd_lower.contains("--force") || cmd_lower.contains("-f")) {
|
|
return (true, "Force push can overwrite remote history and affect other collaborators");
|
|
}
|
|
|
|
// Check for hard reset
|
|
if cmd_lower.contains("reset") && cmd_lower.contains("--hard") {
|
|
return (true, "Hard reset will discard uncommitted changes permanently");
|
|
}
|
|
|
|
// Check for git clean
|
|
if cmd_lower.contains("clean") && (cmd_lower.contains("-f") || cmd_lower.contains("-d")) {
|
|
return (true, "Git clean will permanently delete untracked files");
|
|
}
|
|
|
|
// Check for rebase
|
|
if cmd_lower.contains("rebase") {
|
|
return (true, "Rebase rewrites commit history and can cause conflicts");
|
|
}
|
|
|
|
// Check for amend
|
|
if cmd_lower.contains("commit") && cmd_lower.contains("--amend") {
|
|
return (true, "Amending rewrites the last commit and changes its hash");
|
|
}
|
|
|
|
// Check for filter-branch or filter-repo
|
|
if cmd_lower.contains("filter-branch") || cmd_lower.contains("filter-repo") {
|
|
return (true, "Filter operations rewrite repository history");
|
|
}
|
|
|
|
// Check for branch/tag deletion
|
|
if (cmd_lower.contains("branch") && (cmd_lower.contains("-D") || cmd_lower.contains("-d")))
|
|
|| (cmd_lower.contains("tag") && (cmd_lower.contains("-d") || cmd_lower.contains("--delete")))
|
|
{
|
|
return (true, "This will delete a branch or tag");
|
|
}
|
|
|
|
// Check for reflog expire
|
|
if cmd_lower.contains("reflog") && cmd_lower.contains("expire") {
|
|
return (true, "Expiring reflog removes recovery points for lost commits");
|
|
}
|
|
|
|
// Check for gc with aggressive or prune
|
|
if cmd_lower.contains("gc") && (cmd_lower.contains("--aggressive") || cmd_lower.contains("--prune")) {
|
|
return (true, "Aggressive garbage collection can make recovery difficult");
|
|
}
|
|
|
|
(false, "")
|
|
}
|
|
|
|
/// Format git state for human-readable display
|
|
///
|
|
/// Example output:
|
|
/// ```text
|
|
/// Git Repository: yes
|
|
/// Current branch: feature-branch
|
|
/// Main branch: main
|
|
/// Status: 3 modified, 1 untracked
|
|
/// Remote: https://github.com/user/repo.git
|
|
/// ```
|
|
pub fn format_git_status(state: &GitState) -> String {
|
|
if !state.is_git_repo {
|
|
return "Not a git repository".to_string();
|
|
}
|
|
|
|
let mut lines = Vec::new();
|
|
|
|
lines.push("Git Repository: yes".to_string());
|
|
|
|
if let Some(branch) = &state.current_branch {
|
|
lines.push(format!("Current branch: {}", branch));
|
|
} else {
|
|
lines.push("Current branch: (detached HEAD)".to_string());
|
|
}
|
|
|
|
if let Some(main) = &state.main_branch {
|
|
lines.push(format!("Main branch: {}", main));
|
|
}
|
|
|
|
// Summarize status
|
|
if state.status.is_empty() {
|
|
lines.push("Status: clean working tree".to_string());
|
|
} else {
|
|
let mut modified = 0;
|
|
let mut added = 0;
|
|
let mut deleted = 0;
|
|
let mut renamed = 0;
|
|
let mut untracked = 0;
|
|
|
|
for status in &state.status {
|
|
match status {
|
|
GitFileStatus::Modified { .. } => modified += 1,
|
|
GitFileStatus::Added { .. } => added += 1,
|
|
GitFileStatus::Deleted { .. } => deleted += 1,
|
|
GitFileStatus::Renamed { .. } => renamed += 1,
|
|
GitFileStatus::Untracked { .. } => untracked += 1,
|
|
}
|
|
}
|
|
|
|
let mut status_parts = Vec::new();
|
|
if modified > 0 {
|
|
status_parts.push(format!("{} modified", modified));
|
|
}
|
|
if added > 0 {
|
|
status_parts.push(format!("{} added", added));
|
|
}
|
|
if deleted > 0 {
|
|
status_parts.push(format!("{} deleted", deleted));
|
|
}
|
|
if renamed > 0 {
|
|
status_parts.push(format!("{} renamed", renamed));
|
|
}
|
|
if untracked > 0 {
|
|
status_parts.push(format!("{} untracked", untracked));
|
|
}
|
|
|
|
lines.push(format!("Status: {}", status_parts.join(", ")));
|
|
}
|
|
|
|
if let Some(url) = &state.remote_url {
|
|
lines.push(format!("Remote: {}", url));
|
|
} else {
|
|
lines.push("Remote: (none)".to_string());
|
|
}
|
|
|
|
lines.join("\n")
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_is_safe_git_command() {
|
|
// Safe commands
|
|
assert!(is_safe_git_command("git status"));
|
|
assert!(is_safe_git_command("git log --oneline"));
|
|
assert!(is_safe_git_command("git diff HEAD"));
|
|
assert!(is_safe_git_command("git branch -v"));
|
|
assert!(is_safe_git_command("git remote -v"));
|
|
assert!(is_safe_git_command("git config --get user.name"));
|
|
|
|
// Unsafe commands
|
|
assert!(!is_safe_git_command("git commit -m test"));
|
|
assert!(!is_safe_git_command("git push origin main"));
|
|
assert!(!is_safe_git_command("git branch -D feature"));
|
|
assert!(!is_safe_git_command("git remote add origin url"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_is_destructive_git_command() {
|
|
// Destructive commands
|
|
let (is_dest, msg) = is_destructive_git_command("git push --force origin main");
|
|
assert!(is_dest);
|
|
assert!(msg.contains("Force push"));
|
|
|
|
let (is_dest, msg) = is_destructive_git_command("git reset --hard HEAD~1");
|
|
assert!(is_dest);
|
|
assert!(msg.contains("Hard reset"));
|
|
|
|
let (is_dest, msg) = is_destructive_git_command("git clean -fd");
|
|
assert!(is_dest);
|
|
assert!(msg.contains("clean"));
|
|
|
|
let (is_dest, msg) = is_destructive_git_command("git rebase main");
|
|
assert!(is_dest);
|
|
assert!(msg.contains("Rebase"));
|
|
|
|
let (is_dest, msg) = is_destructive_git_command("git commit --amend");
|
|
assert!(is_dest);
|
|
assert!(msg.contains("Amending"));
|
|
|
|
// Non-destructive commands
|
|
let (is_dest, _) = is_destructive_git_command("git status");
|
|
assert!(!is_dest);
|
|
|
|
let (is_dest, _) = is_destructive_git_command("git log");
|
|
assert!(!is_dest);
|
|
|
|
let (is_dest, _) = is_destructive_git_command("git diff");
|
|
assert!(!is_dest);
|
|
}
|
|
|
|
#[test]
|
|
fn test_git_state_not_a_repo() {
|
|
let state = GitState::not_a_repo();
|
|
assert!(!state.is_git_repo);
|
|
assert!(state.current_branch.is_none());
|
|
assert!(state.main_branch.is_none());
|
|
assert!(state.status.is_empty());
|
|
assert!(!state.has_uncommitted_changes);
|
|
assert!(state.remote_url.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn test_git_file_status_path() {
|
|
let status = GitFileStatus::Modified {
|
|
path: "test.rs".to_string(),
|
|
};
|
|
assert_eq!(status.path(), "test.rs");
|
|
|
|
let status = GitFileStatus::Renamed {
|
|
from: "old.rs".to_string(),
|
|
to: "new.rs".to_string(),
|
|
};
|
|
assert_eq!(status.path(), "new.rs");
|
|
}
|
|
|
|
#[test]
|
|
fn test_format_git_status_not_repo() {
|
|
let state = GitState::not_a_repo();
|
|
let formatted = format_git_status(&state);
|
|
assert_eq!(formatted, "Not a git repository");
|
|
}
|
|
|
|
#[test]
|
|
fn test_format_git_status_clean() {
|
|
let state = GitState {
|
|
is_git_repo: true,
|
|
current_branch: Some("main".to_string()),
|
|
main_branch: Some("main".to_string()),
|
|
status: Vec::new(),
|
|
has_uncommitted_changes: false,
|
|
remote_url: Some("https://github.com/user/repo.git".to_string()),
|
|
};
|
|
|
|
let formatted = format_git_status(&state);
|
|
assert!(formatted.contains("Git Repository: yes"));
|
|
assert!(formatted.contains("Current branch: main"));
|
|
assert!(formatted.contains("clean working tree"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_format_git_status_with_changes() {
|
|
let state = GitState {
|
|
is_git_repo: true,
|
|
current_branch: Some("feature".to_string()),
|
|
main_branch: Some("main".to_string()),
|
|
status: vec![
|
|
GitFileStatus::Modified {
|
|
path: "file1.rs".to_string(),
|
|
},
|
|
GitFileStatus::Modified {
|
|
path: "file2.rs".to_string(),
|
|
},
|
|
GitFileStatus::Untracked {
|
|
path: "new.rs".to_string(),
|
|
},
|
|
],
|
|
has_uncommitted_changes: true,
|
|
remote_url: None,
|
|
};
|
|
|
|
let formatted = format_git_status(&state);
|
|
assert!(formatted.contains("2 modified"));
|
|
assert!(formatted.contains("1 untracked"));
|
|
}
|
|
}
|