use std::collections::HashSet;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use crate::error::{Error, Result};
use crate::fs::FileSystem;
use super::config::GitConfig;
#[derive(Debug, Clone)]
pub struct GitClient {
config: GitConfig,
}
#[derive(Debug, Clone, Default)]
pub struct GitStatus {
pub branch: String,
pub staged: Vec<String>,
pub modified: Vec<String>,
pub untracked: Vec<String>,
}
#[derive(Debug, Clone)]
pub struct GitLogEntry {
pub hash: String,
pub author: String,
pub timestamp: i64,
pub message: String,
}
#[derive(Debug, Clone)]
pub struct Remote {
pub name: String,
pub url: String,
}
#[derive(Debug, Clone)]
pub struct Branch {
pub name: String,
pub current: bool,
}
impl GitClient {
pub fn new(config: GitConfig) -> Self {
Self { config }
}
pub fn config(&self) -> &GitConfig {
&self.config
}
pub async fn init(&self, fs: &Arc<dyn FileSystem>, repo_path: &Path) -> Result<String> {
let git_dir = repo_path.join(".git");
if fs.exists(&git_dir).await? {
return Ok(format!(
"Reinitialized existing Git repository in {}/.git/\n",
repo_path.display()
));
}
fs.mkdir(&git_dir, true).await?;
fs.mkdir(&git_dir.join("objects"), true).await?;
fs.mkdir(&git_dir.join("refs"), true).await?;
fs.mkdir(&git_dir.join("refs/heads"), true).await?;
fs.mkdir(&git_dir.join("refs/tags"), true).await?;
fs.write_file(&git_dir.join("HEAD"), b"ref: refs/heads/master\n")
.await?;
let config_content = format!(
"[core]\n\
\trepositoryformatversion = 0\n\
\tfilemode = true\n\
\tbare = false\n\
[user]\n\
\tname = {}\n\
\temail = {}\n",
self.config.author_name, self.config.author_email
);
fs.write_file(&git_dir.join("config"), config_content.as_bytes())
.await?;
fs.write_file(&git_dir.join("index"), b"").await?;
Ok(format!(
"Initialized empty Git repository in {}/.git/\n",
repo_path.display()
))
}
pub async fn config_get(
&self,
fs: &Arc<dyn FileSystem>,
repo_path: &Path,
key: &str,
) -> Result<Option<String>> {
let config_path = repo_path.join(".git/config");
if !fs.exists(&config_path).await? {
return Err(Error::Internal(format!(
"fatal: not a git repository: {}",
repo_path.display()
)));
}
let content = fs.read_file(&config_path).await?;
let content = String::from_utf8_lossy(&content);
let key_lower = key.to_lowercase();
let parts: Vec<&str> = key_lower.split('.').collect();
if parts.len() != 2 {
return Ok(None);
}
let (section, name) = (parts[0], parts[1]);
let mut in_section = false;
for line in content.lines() {
let line = line.trim();
if line.starts_with('[') && line.ends_with(']') {
let sect = &line[1..line.len() - 1].to_lowercase();
in_section = sect == section;
continue;
}
if in_section
&& let Some((k, v)) = line.split_once('=')
&& k.trim().to_lowercase() == name
{
return Ok(Some(super::sanitize_git_output(v.trim())));
}
}
Ok(None)
}
pub async fn config_set(
&self,
fs: &Arc<dyn FileSystem>,
repo_path: &Path,
key: &str,
value: &str,
) -> Result<()> {
let config_path = repo_path.join(".git/config");
if !fs.exists(&config_path).await? {
return Err(Error::Internal(format!(
"fatal: not a git repository: {}",
repo_path.display()
)));
}
let content = fs.read_file(&config_path).await?;
let content = String::from_utf8_lossy(&content);
let key_lower = key.to_lowercase();
let parts: Vec<&str> = key_lower.split('.').collect();
if parts.len() != 2 {
return Err(Error::Internal(format!("error: invalid key: {}", key)));
}
let (section, name) = (parts[0], parts[1]);
let mut new_content = String::new();
let mut in_section = false;
let mut found = false;
let mut section_exists = false;
for line in content.lines() {
let trimmed = line.trim();
if trimmed.starts_with('[') && trimmed.ends_with(']') {
if in_section && !found {
new_content.push_str(&format!("\t{} = {}\n", name, value));
found = true;
}
let sect = &trimmed[1..trimmed.len() - 1].to_lowercase();
in_section = sect == section;
if in_section {
section_exists = true;
}
new_content.push_str(line);
new_content.push('\n');
continue;
}
if in_section
&& let Some((k, _)) = trimmed.split_once('=')
&& k.trim().to_lowercase() == name
{
new_content.push_str(&format!("\t{} = {}\n", name, value));
found = true;
continue;
}
new_content.push_str(line);
new_content.push('\n');
}
if in_section && !found {
new_content.push_str(&format!("\t{} = {}\n", name, value));
}
if !section_exists {
new_content.push_str(&format!("[{}]\n\t{} = {}\n", section, name, value));
}
fs.write_file(&config_path, new_content.as_bytes()).await?;
Ok(())
}
pub async fn add(
&self,
fs: &Arc<dyn FileSystem>,
repo_path: &Path,
paths: &[&str],
) -> Result<()> {
let git_dir = repo_path.join(".git");
let index_path = git_dir.join("index");
if !fs.exists(&git_dir).await? {
return Err(Error::Internal(format!(
"fatal: not a git repository: {}",
repo_path.display()
)));
}
let mut staged: HashSet<String> = HashSet::new();
if fs.exists(&index_path).await? {
let content = fs.read_file(&index_path).await?;
let content = String::from_utf8_lossy(&content);
for line in content.lines() {
if !line.is_empty() {
staged.insert(line.to_string());
}
}
}
for path_str in paths {
let path = if Path::new(path_str).is_absolute() {
PathBuf::from(path_str)
} else {
repo_path.join(path_str)
};
if *path_str == "." {
self.add_directory_recursive(fs, repo_path, repo_path, &mut staged)
.await?;
continue;
}
if fs.exists(&path).await? {
let rel_path = path
.strip_prefix(repo_path)
.unwrap_or(&path)
.to_string_lossy()
.to_string();
if !rel_path.starts_with(".git") {
staged.insert(rel_path);
}
}
}
let index_content: String = staged.into_iter().collect::<Vec<_>>().join("\n");
fs.write_file(&index_path, index_content.as_bytes()).await?;
Ok(())
}
async fn add_directory_recursive(
&self,
fs: &Arc<dyn FileSystem>,
repo_path: &Path,
dir: &Path,
staged: &mut HashSet<String>,
) -> Result<()> {
let entries = fs.read_dir(dir).await?;
for entry in entries {
let name = &entry.name;
if name == ".git" {
continue;
}
let path = dir.join(name);
let rel_path = path
.strip_prefix(repo_path)
.unwrap_or(&path)
.to_string_lossy()
.to_string();
if entry.metadata.file_type.is_dir() {
Box::pin(self.add_directory_recursive(fs, repo_path, &path, staged)).await?;
} else {
staged.insert(rel_path);
}
}
Ok(())
}
pub async fn commit(
&self,
fs: &Arc<dyn FileSystem>,
repo_path: &Path,
message: &str,
) -> Result<String> {
let git_dir = repo_path.join(".git");
let index_path = git_dir.join("index");
let commits_path = git_dir.join("commits");
if !fs.exists(&git_dir).await? {
return Err(Error::Internal(format!(
"fatal: not a git repository: {}",
repo_path.display()
)));
}
if !fs.exists(&index_path).await? {
return Err(Error::Internal(
"nothing to commit, working tree clean".to_string(),
));
}
let index_content = fs.read_file(&index_path).await?;
let index_content = String::from_utf8_lossy(&index_content);
if index_content.trim().is_empty() {
return Err(Error::Internal(
"nothing to commit, working tree clean".to_string(),
));
}
let timestamp = chrono::Utc::now().timestamp();
let hash = format!("{:08x}", timestamp as u32 ^ 0xdeadbeef);
let mut commits = String::new();
if fs.exists(&commits_path).await? {
let content = fs.read_file(&commits_path).await?;
commits = String::from_utf8_lossy(&content).to_string();
}
let commit_entry = format!(
"{}|{}|{}|{}|{}\n",
hash,
self.config.author_name,
self.config.author_email,
timestamp,
message.replace('|', "\\|").replace('\n', "\\n")
);
commits = commit_entry + &commits;
if !fs.exists(commits_path.parent().unwrap_or(&git_dir)).await? {
fs.mkdir(&git_dir, true).await?;
}
fs.write_file(&commits_path, commits.as_bytes()).await?;
let tracked_path = git_dir.join("tracked");
let mut tracked: HashSet<String> = HashSet::new();
if fs.exists(&tracked_path).await? {
let content = fs.read_file(&tracked_path).await?;
let content = String::from_utf8_lossy(&content);
for line in content.lines() {
if !line.is_empty() {
tracked.insert(line.to_string());
}
}
}
for line in index_content.lines() {
if !line.is_empty() {
tracked.insert(line.to_string());
}
}
let tracked_content: String = tracked.into_iter().collect::<Vec<_>>().join("\n");
fs.write_file(&tracked_path, tracked_content.as_bytes())
.await?;
fs.write_file(&index_path, b"").await?;
let head_ref_path = git_dir.join("refs/heads/master");
fs.write_file(&head_ref_path, hash.as_bytes()).await?;
Ok(format!(
"[master {}] {}\n",
&hash[..7.min(hash.len())],
message.lines().next().unwrap_or(message)
))
}
pub async fn status(&self, fs: &Arc<dyn FileSystem>, repo_path: &Path) -> Result<GitStatus> {
let git_dir = repo_path.join(".git");
let index_path = git_dir.join("index");
if !fs.exists(&git_dir).await? {
return Err(Error::Internal(format!(
"fatal: not a git repository: {}",
repo_path.display()
)));
}
let mut status = GitStatus {
branch: "master".to_string(),
..Default::default()
};
let head_path = git_dir.join("HEAD");
if fs.exists(&head_path).await? {
let content = fs.read_file(&head_path).await?;
let content = String::from_utf8_lossy(&content);
if let Some(branch) = content.strip_prefix("ref: refs/heads/") {
status.branch = branch.trim().to_string();
}
}
let mut staged_files: HashSet<String> = HashSet::new();
if fs.exists(&index_path).await? {
let content = fs.read_file(&index_path).await?;
let content = String::from_utf8_lossy(&content);
for line in content.lines() {
if !line.is_empty() {
staged_files.insert(line.to_string());
status.staged.push(line.to_string());
}
}
}
let tracked_path = git_dir.join("tracked");
let mut tracked_files: HashSet<String> = HashSet::new();
if fs.exists(&tracked_path).await? {
let content = fs.read_file(&tracked_path).await?;
let content = String::from_utf8_lossy(&content);
for line in content.lines() {
if !line.is_empty() {
tracked_files.insert(line.to_string());
}
}
}
let all_files = self.list_files_recursive(fs, repo_path, repo_path).await?;
for file in all_files {
if !staged_files.contains(&file) && !tracked_files.contains(&file) {
status.untracked.push(file);
}
}
status.staged.sort();
status.modified.sort();
status.untracked.sort();
Ok(status)
}
async fn list_files_recursive(
&self,
fs: &Arc<dyn FileSystem>,
repo_path: &Path,
dir: &Path,
) -> Result<Vec<String>> {
let mut files = Vec::new();
let entries = fs.read_dir(dir).await?;
for entry in entries {
let name = &entry.name;
if name == ".git" {
continue;
}
let path = dir.join(name);
let rel_path = path
.strip_prefix(repo_path)
.unwrap_or(&path)
.to_string_lossy()
.to_string();
if entry.metadata.file_type.is_dir() {
let sub_files = Box::pin(self.list_files_recursive(fs, repo_path, &path)).await?;
files.extend(sub_files);
} else {
files.push(rel_path);
}
}
Ok(files)
}
pub async fn log(
&self,
fs: &Arc<dyn FileSystem>,
repo_path: &Path,
limit: Option<usize>,
) -> Result<Vec<GitLogEntry>> {
let git_dir = repo_path.join(".git");
let commits_path = git_dir.join("commits");
if !fs.exists(&git_dir).await? {
return Err(Error::Internal(format!(
"fatal: not a git repository: {}",
repo_path.display()
)));
}
let mut entries = Vec::new();
if fs.exists(&commits_path).await? {
let content = fs.read_file(&commits_path).await?;
let content = String::from_utf8_lossy(&content);
for line in content.lines() {
if line.is_empty() {
continue;
}
let parts: Vec<&str> = line.splitn(5, '|').collect();
if parts.len() >= 5 {
entries.push(GitLogEntry {
hash: parts[0].to_string(),
author: format!("{} <{}>", parts[1], parts[2]),
timestamp: parts[3].parse().unwrap_or(0),
message: parts[4].replace("\\|", "|").replace("\\n", "\n"),
});
}
if let Some(limit) = limit
&& entries.len() >= limit
{
break;
}
}
}
Ok(entries)
}
pub fn format_log(&self, entries: &[GitLogEntry]) -> String {
let mut output = String::new();
for entry in entries {
output.push_str(&format!("commit {}\n", entry.hash));
output.push_str(&format!(
"Author: {}\n",
super::sanitize_git_output(&entry.author)
));
if let Some(dt) = chrono::DateTime::from_timestamp(entry.timestamp, 0) {
output.push_str(&format!(
"Date: {}\n",
dt.format("%a %b %d %H:%M:%S %Y %z")
));
}
output.push('\n');
let sanitized_msg = super::sanitize_git_output(&entry.message);
for line in sanitized_msg.lines() {
output.push_str(&format!(" {}\n", line));
}
output.push('\n');
}
output
}
pub fn format_status(&self, status: &GitStatus) -> String {
let mut output = String::new();
output.push_str(&format!(
"On branch {}\n",
super::sanitize_git_output(&status.branch)
));
if !status.staged.is_empty() {
output.push_str("\nChanges to be committed:\n");
output.push_str(" (use \"git restore --staged <file>...\" to unstage)\n");
for file in &status.staged {
output.push_str(&format!(
"\tnew file: {}\n",
super::sanitize_git_output(file)
));
}
}
if !status.modified.is_empty() {
output.push_str("\nChanges not staged for commit:\n");
output.push_str(" (use \"git add <file>...\" to update what will be committed)\n");
for file in &status.modified {
output.push_str(&format!(
"\tmodified: {}\n",
super::sanitize_git_output(file)
));
}
}
if !status.untracked.is_empty() {
output.push_str("\nUntracked files:\n");
output.push_str(" (use \"git add <file>...\" to include in what will be committed)\n");
for file in &status.untracked {
output.push_str(&format!("\t{}\n", super::sanitize_git_output(file)));
}
}
if status.staged.is_empty() && status.modified.is_empty() && status.untracked.is_empty() {
output.push_str("\nnothing to commit, working tree clean\n");
}
output
}
pub async fn remote_add(
&self,
fs: &Arc<dyn FileSystem>,
repo_path: &Path,
name: &str,
url: &str,
) -> Result<()> {
let git_dir = repo_path.join(".git");
if !fs.exists(&git_dir).await? {
return Err(Error::Internal(format!(
"fatal: not a git repository: {}",
repo_path.display()
)));
}
self.config.is_url_allowed(url).map_err(Error::Internal)?;
let remotes_path = git_dir.join("remotes");
let mut remotes = String::new();
if fs.exists(&remotes_path).await? {
let content = fs.read_file(&remotes_path).await?;
remotes = String::from_utf8_lossy(&content).to_string();
for line in remotes.lines() {
if let Some((existing_name, _)) = line.split_once('|')
&& existing_name == name
{
return Err(Error::Internal(format!(
"error: remote {} already exists",
name
)));
}
}
}
remotes.push_str(&format!("{}|{}\n", name, url));
fs.write_file(&remotes_path, remotes.as_bytes()).await?;
Ok(())
}
pub async fn remote_remove(
&self,
fs: &Arc<dyn FileSystem>,
repo_path: &Path,
name: &str,
) -> Result<()> {
let git_dir = repo_path.join(".git");
let remotes_path = git_dir.join("remotes");
if !fs.exists(&git_dir).await? {
return Err(Error::Internal(format!(
"fatal: not a git repository: {}",
repo_path.display()
)));
}
if !fs.exists(&remotes_path).await? {
return Err(Error::Internal(format!("error: no such remote: {}", name)));
}
let content = fs.read_file(&remotes_path).await?;
let content = String::from_utf8_lossy(&content);
let mut found = false;
let mut new_content = String::new();
for line in content.lines() {
if let Some((existing_name, _)) = line.split_once('|')
&& existing_name == name
{
found = true;
continue;
}
new_content.push_str(line);
new_content.push('\n');
}
if !found {
return Err(Error::Internal(format!("error: no such remote: {}", name)));
}
fs.write_file(&remotes_path, new_content.as_bytes()).await?;
Ok(())
}
pub async fn remote_list(
&self,
fs: &Arc<dyn FileSystem>,
repo_path: &Path,
) -> Result<Vec<Remote>> {
let git_dir = repo_path.join(".git");
let remotes_path = git_dir.join("remotes");
if !fs.exists(&git_dir).await? {
return Err(Error::Internal(format!(
"fatal: not a git repository: {}",
repo_path.display()
)));
}
let mut remotes = Vec::new();
if fs.exists(&remotes_path).await? {
let content = fs.read_file(&remotes_path).await?;
let content = String::from_utf8_lossy(&content);
for line in content.lines() {
if let Some((name, url)) = line.split_once('|') {
remotes.push(Remote {
name: name.to_string(),
url: url.to_string(),
});
}
}
}
Ok(remotes)
}
pub async fn clone(
&self,
_fs: &Arc<dyn FileSystem>,
url: &str,
_dest: &Path,
) -> Result<String> {
self.config.is_url_allowed(url).map_err(Error::Internal)?;
Err(Error::Internal(format!(
"git clone: network operations not supported in virtual mode\n\
hint: URL '{}' passed allowlist validation\n\
hint: to clone, use a pre-populated virtual filesystem instead",
url
)))
}
pub async fn fetch(
&self,
fs: &Arc<dyn FileSystem>,
repo_path: &Path,
remote: &str,
) -> Result<String> {
let remotes = self.remote_list(fs, repo_path).await?;
let remote_entry = remotes
.iter()
.find(|r| r.name == remote)
.ok_or_else(|| Error::Internal(format!("error: remote '{}' not found", remote)))?;
self.config
.is_url_allowed(&remote_entry.url)
.map_err(Error::Internal)?;
Err(Error::Internal(format!(
"git fetch: network operations not supported in virtual mode\n\
hint: remote '{}' URL '{}' passed allowlist validation",
remote, remote_entry.url
)))
}
pub async fn push(
&self,
fs: &Arc<dyn FileSystem>,
repo_path: &Path,
remote: &str,
) -> Result<String> {
let remotes = self.remote_list(fs, repo_path).await?;
let remote_entry = remotes
.iter()
.find(|r| r.name == remote)
.ok_or_else(|| Error::Internal(format!("error: remote '{}' not found", remote)))?;
self.config
.is_url_allowed(&remote_entry.url)
.map_err(Error::Internal)?;
Err(Error::Internal(format!(
"git push: network operations not supported in virtual mode\n\
hint: remote '{}' URL '{}' passed allowlist validation",
remote, remote_entry.url
)))
}
pub async fn pull(
&self,
fs: &Arc<dyn FileSystem>,
repo_path: &Path,
remote: &str,
) -> Result<String> {
let remotes = self.remote_list(fs, repo_path).await?;
let remote_entry = remotes
.iter()
.find(|r| r.name == remote)
.ok_or_else(|| Error::Internal(format!("error: remote '{}' not found", remote)))?;
self.config
.is_url_allowed(&remote_entry.url)
.map_err(Error::Internal)?;
Err(Error::Internal(format!(
"git pull: network operations not supported in virtual mode\n\
hint: remote '{}' URL '{}' passed allowlist validation",
remote, remote_entry.url
)))
}
pub async fn branch_list(
&self,
fs: &Arc<dyn FileSystem>,
repo_path: &Path,
) -> Result<Vec<Branch>> {
let git_dir = repo_path.join(".git");
let refs_heads = git_dir.join("refs/heads");
if !fs.exists(&git_dir).await? {
return Err(Error::Internal(format!(
"fatal: not a git repository: {}",
repo_path.display()
)));
}
let current_branch = self.get_current_branch(fs, repo_path).await?;
let mut branches = Vec::new();
if fs.exists(&refs_heads).await? {
let entries = fs.read_dir(&refs_heads).await?;
for entry in entries {
if !entry.metadata.file_type.is_dir() {
let name = entry.name.clone();
branches.push(Branch {
current: name == current_branch,
name,
});
}
}
}
branches.sort_by(|a, b| {
if a.current && !b.current {
std::cmp::Ordering::Less
} else if !a.current && b.current {
std::cmp::Ordering::Greater
} else {
a.name.cmp(&b.name)
}
});
Ok(branches)
}
async fn get_current_branch(
&self,
fs: &Arc<dyn FileSystem>,
repo_path: &Path,
) -> Result<String> {
let git_dir = repo_path.join(".git");
let head_path = git_dir.join("HEAD");
if fs.exists(&head_path).await? {
let content = fs.read_file(&head_path).await?;
let content = String::from_utf8_lossy(&content);
if let Some(branch) = content.trim().strip_prefix("ref: refs/heads/") {
return Ok(branch.to_string());
}
}
Ok("master".to_string())
}
fn validate_ref_name(name: &str) -> Result<()> {
if name.is_empty() {
return Err(Error::Internal(
"fatal: invalid ref name: empty".to_string(),
));
}
if name.contains("..") || name.contains("//") || name.starts_with('/') {
return Err(Error::Internal(format!(
"fatal: '{name}' is not a valid branch name (path traversal)"
)));
}
if name.starts_with('.') || name.ends_with('.') || name.ends_with(".lock") {
return Err(Error::Internal(format!(
"fatal: '{name}' is not a valid branch name"
)));
}
if name.starts_with('-') {
return Err(Error::Internal(format!(
"fatal: '{name}' is not a valid branch name (starts with dash)"
)));
}
for ch in name.chars() {
if ch.is_ascii_control() || ch == ' ' || ch == '~' || ch == '^' || ch == ':' {
return Err(Error::Internal(format!(
"fatal: '{name}' is not a valid branch name (invalid character)"
)));
}
}
Ok(())
}
pub async fn branch_create(
&self,
fs: &Arc<dyn FileSystem>,
repo_path: &Path,
name: &str,
) -> Result<()> {
Self::validate_ref_name(name)?;
let git_dir = repo_path.join(".git");
let refs_heads = git_dir.join("refs/heads");
let branch_path = refs_heads.join(name);
if !fs.exists(&git_dir).await? {
return Err(Error::Internal(format!(
"fatal: not a git repository: {}",
repo_path.display()
)));
}
if fs.exists(&branch_path).await? {
return Err(Error::Internal(format!(
"fatal: a branch named '{}' already exists",
name
)));
}
let head_ref = refs_heads.join(self.get_current_branch(fs, repo_path).await?);
let commit_hash = if fs.exists(&head_ref).await? {
let content = fs.read_file(&head_ref).await?;
String::from_utf8_lossy(&content).trim().to_string()
} else {
return Err(Error::Internal(
"fatal: not a valid object name: 'master'".to_string(),
));
};
if !fs.exists(&refs_heads).await? {
fs.mkdir(&refs_heads, true).await?;
}
fs.write_file(&branch_path, commit_hash.as_bytes()).await?;
Ok(())
}
pub async fn branch_delete(
&self,
fs: &Arc<dyn FileSystem>,
repo_path: &Path,
name: &str,
) -> Result<()> {
Self::validate_ref_name(name)?;
let git_dir = repo_path.join(".git");
let branch_path = git_dir.join("refs/heads").join(name);
if !fs.exists(&git_dir).await? {
return Err(Error::Internal(format!(
"fatal: not a git repository: {}",
repo_path.display()
)));
}
let current = self.get_current_branch(fs, repo_path).await?;
if current == name {
return Err(Error::Internal(format!(
"error: cannot delete branch '{}' checked out at '{}'",
name,
repo_path.display()
)));
}
if !fs.exists(&branch_path).await? {
return Err(Error::Internal(format!(
"error: branch '{}' not found",
name
)));
}
fs.remove(&branch_path, false).await?;
Ok(())
}
pub async fn checkout(
&self,
fs: &Arc<dyn FileSystem>,
repo_path: &Path,
target: &str,
) -> Result<String> {
Self::validate_ref_name(target)?;
let git_dir = repo_path.join(".git");
let head_path = git_dir.join("HEAD");
let branch_path = git_dir.join("refs/heads").join(target);
if !fs.exists(&git_dir).await? {
return Err(Error::Internal(format!(
"fatal: not a git repository: {}",
repo_path.display()
)));
}
if fs.exists(&branch_path).await? {
let head_content = format!("ref: refs/heads/{}", target);
fs.write_file(&head_path, head_content.as_bytes()).await?;
Ok(format!("Switched to branch '{}'\n", target))
} else {
if target.len() >= 7 && target.chars().all(|c| c.is_ascii_hexdigit()) {
fs.write_file(&head_path, target.as_bytes()).await?;
Ok(format!(
"Note: switching to '{}'.\n\n\
You are in 'detached HEAD' state.\n",
target
))
} else {
Err(Error::Internal(format!(
"error: pathspec '{}' did not match any file(s) known to git",
target
)))
}
}
}
pub async fn diff(
&self,
fs: &Arc<dyn FileSystem>,
repo_path: &Path,
_from: Option<&str>,
_to: Option<&str>,
) -> Result<String> {
let git_dir = repo_path.join(".git");
if !fs.exists(&git_dir).await? {
return Err(Error::Internal(format!(
"fatal: not a git repository: {}",
repo_path.display()
)));
}
Ok("# Diff output (simplified in virtual mode)\n".to_string())
}
pub async fn reset(
&self,
fs: &Arc<dyn FileSystem>,
repo_path: &Path,
mode: &str,
target: Option<&str>,
) -> Result<String> {
let git_dir = repo_path.join(".git");
if !fs.exists(&git_dir).await? {
return Err(Error::Internal(format!(
"fatal: not a git repository: {}",
repo_path.display()
)));
}
let _target = target.unwrap_or("HEAD");
match mode {
"--soft" | "--mixed" | "--hard" => {
let index_path = git_dir.join("index");
fs.write_file(&index_path, b"").await?;
Ok(match mode {
"--soft" => "".to_string(),
"--mixed" => "Unstaged changes after reset:\n".to_string(),
"--hard" => "HEAD is now at (reset complete)\n".to_string(),
_ => "".to_string(),
})
}
_ => Err(Error::Internal(format!(
"error: unknown switch `{}`",
mode.trim_start_matches('-')
))),
}
}
pub fn format_branch_list(&self, branches: &[Branch]) -> String {
let mut output = String::new();
for branch in branches {
let name = super::sanitize_git_output(&branch.name);
if branch.current {
output.push_str(&format!("* {}\n", name));
} else {
output.push_str(&format!(" {}\n", name));
}
}
output
}
pub async fn show(
&self,
fs: &Arc<dyn FileSystem>,
repo_path: &Path,
target: Option<&str>,
) -> Result<String> {
let git_dir = repo_path.join(".git");
if !fs.exists(&git_dir).await? {
return Err(Error::Internal(format!(
"fatal: not a git repository: {}",
repo_path.display()
)));
}
let target = target.unwrap_or("HEAD");
if let Some((rev, path)) = target.split_once(':') {
if rev != "HEAD" {
return Err(Error::Execution(format!(
"fatal: unsupported revision '{}' (only HEAD is supported in virtual git)",
rev
)));
}
let file_path = repo_path.join(path);
if fs.exists(&file_path).await? {
let content = fs.read_file(&file_path).await?;
return Ok(super::sanitize_git_output(&String::from_utf8_lossy(
&content,
)));
}
return Err(Error::Internal(format!(
"fatal: path '{}' does not exist",
path
)));
}
let entries = self.log(fs, repo_path, None).await?;
if entries.is_empty() {
return Err(Error::Internal(
"fatal: bad default revision 'HEAD'".to_string(),
));
}
let entry = if target == "HEAD" {
&entries[0]
} else {
entries
.iter()
.find(|e| e.hash.starts_with(target))
.ok_or_else(|| Error::Internal(format!("fatal: bad object {}", target)))?
};
Ok(self.format_log(std::slice::from_ref(entry)))
}
pub async fn ls_files(
&self,
fs: &Arc<dyn FileSystem>,
repo_path: &Path,
) -> Result<Vec<String>> {
let git_dir = repo_path.join(".git");
if !fs.exists(&git_dir).await? {
return Err(Error::Internal(format!(
"fatal: not a git repository: {}",
repo_path.display()
)));
}
let tracked_path = git_dir.join("tracked");
let mut files = Vec::new();
if fs.exists(&tracked_path).await? {
let content = fs.read_file(&tracked_path).await?;
let content = String::from_utf8_lossy(&content);
for line in content.lines() {
if !line.is_empty() {
files.push(line.to_string());
}
}
}
let index_path = git_dir.join("index");
if fs.exists(&index_path).await? {
let content = fs.read_file(&index_path).await?;
let content = String::from_utf8_lossy(&content);
for line in content.lines() {
if !line.is_empty() && !files.contains(&line.to_string()) {
files.push(line.to_string());
}
}
}
files.sort();
Ok(files)
}
pub async fn rev_parse(
&self,
fs: &Arc<dyn FileSystem>,
repo_path: &Path,
args: &[&str],
) -> Result<String> {
let git_dir = repo_path.join(".git");
if !fs.exists(&git_dir).await? {
return Err(Error::Internal(
"fatal: not a git repository (or any parent up to mount point /)\n\
Stopping at filesystem boundary (GIT_DISCOVERY_ACROSS_FILESYSTEM not set)."
.to_string(),
));
}
let mut output = String::new();
let mut i = 0;
while i < args.len() {
match args[i] {
"--show-toplevel" => {
output.push_str(&format!("{}\n", repo_path.display()));
}
"--git-dir" => {
output.push_str(&format!("{}\n", git_dir.display()));
}
"--is-inside-work-tree" => {
output.push_str("true\n");
}
"--abbrev-ref" => {
i += 1;
if i < args.len() && args[i] == "HEAD" {
let head_content = fs.read_file(&git_dir.join("HEAD")).await?;
let head = String::from_utf8_lossy(&head_content);
if let Some(branch) = head.trim().strip_prefix("ref: refs/heads/") {
output.push_str(&format!("{}\n", super::sanitize_git_output(branch)));
} else {
output.push_str(&format!(
"{}\n",
super::sanitize_git_output(head.trim())
));
}
}
}
"HEAD" => {
let head_content = fs.read_file(&git_dir.join("HEAD")).await?;
let head = String::from_utf8_lossy(&head_content);
if let Some(branch) = head.trim().strip_prefix("ref: refs/heads/") {
let ref_path = git_dir.join(format!("refs/heads/{}", branch));
if fs.exists(&ref_path).await? {
let hash = fs.read_file(&ref_path).await?;
output.push_str(&format!(
"{}\n",
super::sanitize_git_output(String::from_utf8_lossy(&hash).trim())
));
} else {
return Err(Error::Internal(
"fatal: ambiguous argument 'HEAD': unknown revision".to_string(),
));
}
} else {
output.push_str(&format!("{}\n", super::sanitize_git_output(head.trim())));
}
}
arg => {
let ref_path = git_dir.join(format!("refs/heads/{}", arg));
if fs.exists(&ref_path).await? {
let hash = fs.read_file(&ref_path).await?;
output.push_str(&format!(
"{}\n",
super::sanitize_git_output(String::from_utf8_lossy(&hash).trim())
));
} else {
return Err(Error::Internal(format!(
"fatal: ambiguous argument '{}': unknown revision",
arg
)));
}
}
}
i += 1;
}
Ok(output)
}
pub async fn restore(
&self,
fs: &Arc<dyn FileSystem>,
repo_path: &Path,
paths: &[&str],
staged: bool,
) -> Result<String> {
let git_dir = repo_path.join(".git");
if !fs.exists(&git_dir).await? {
return Err(Error::Internal(format!(
"fatal: not a git repository: {}",
repo_path.display()
)));
}
if staged {
let index_path = git_dir.join("index");
if fs.exists(&index_path).await? {
let content = fs.read_file(&index_path).await?;
let content = String::from_utf8_lossy(&content);
let paths_set: HashSet<&str> = paths.iter().copied().collect();
let new_index: String = content
.lines()
.filter(|l| !l.is_empty() && !paths_set.contains(l.trim()))
.collect::<Vec<_>>()
.join("\n");
fs.write_file(&index_path, new_index.as_bytes()).await?;
}
return Ok(String::new());
}
for path in paths {
let file_path = repo_path.join(path);
if !fs.exists(&file_path).await? {
return Err(Error::Internal(format!(
"error: pathspec '{}' did not match any file(s) known to git",
path
)));
}
}
Ok(String::new())
}
pub async fn merge_base(
&self,
fs: &Arc<dyn FileSystem>,
repo_path: &Path,
ref1: &str,
ref2: &str,
) -> Result<String> {
let git_dir = repo_path.join(".git");
if !fs.exists(&git_dir).await? {
return Err(Error::Internal(format!(
"fatal: not a git repository: {}",
repo_path.display()
)));
}
let hash1 = self.resolve_ref(fs, &git_dir, ref1).await?;
let hash2 = self.resolve_ref(fs, &git_dir, ref2).await?;
let entries = self.log(fs, repo_path, None).await?;
if entries.is_empty() {
return Err(Error::Internal("fatal: no commits yet".to_string()));
}
let pos1 = entries.iter().position(|e| e.hash.starts_with(&hash1));
let pos2 = entries.iter().position(|e| e.hash.starts_with(&hash2));
match (pos1, pos2) {
(Some(p1), Some(p2)) => {
let base_idx = p1.max(p2); Ok(format!("{}\n", entries[base_idx].hash))
}
_ => {
let last = entries.last().expect("entries is non-empty");
Ok(format!("{}\n", last.hash))
}
}
}
pub async fn grep(
&self,
fs: &Arc<dyn FileSystem>,
repo_path: &Path,
pattern: &str,
paths: &[&str],
) -> Result<String> {
let git_dir = repo_path.join(".git");
if !fs.exists(&git_dir).await? {
return Err(Error::Internal(format!(
"fatal: not a git repository: {}",
repo_path.display()
)));
}
let files = if paths.is_empty() {
self.ls_files(fs, repo_path).await?
} else {
paths.iter().map(|p| p.to_string()).collect()
};
let mut output = String::new();
for file in &files {
let file_path = repo_path.join(file);
if !fs.exists(&file_path).await? {
continue;
}
let content = fs.read_file(&file_path).await?;
let content = String::from_utf8_lossy(&content);
for (i, line) in content.lines().enumerate() {
if line.contains(pattern) {
output.push_str(&format!(
"{}:{}:{}\n",
super::sanitize_git_output(file),
i + 1,
super::sanitize_git_output(line)
));
}
}
}
Ok(output)
}
async fn resolve_ref(
&self,
fs: &Arc<dyn FileSystem>,
git_dir: &Path,
refspec: &str,
) -> Result<String> {
if refspec == "HEAD" {
let head_content = fs.read_file(&git_dir.join("HEAD")).await?;
let head = String::from_utf8_lossy(&head_content);
if let Some(branch) = head.trim().strip_prefix("ref: refs/heads/") {
let ref_path = git_dir.join(format!("refs/heads/{}", branch));
if fs.exists(&ref_path).await? {
let hash = fs.read_file(&ref_path).await?;
return Ok(String::from_utf8_lossy(&hash).trim().to_string());
}
}
return Ok(head.trim().to_string());
}
let ref_path = git_dir.join(format!("refs/heads/{}", refspec));
if fs.exists(&ref_path).await? {
let hash = fs.read_file(&ref_path).await?;
return Ok(String::from_utf8_lossy(&hash).trim().to_string());
}
Ok(refspec.to_string())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::fs::InMemoryFs;
async fn create_test_fs() -> Arc<dyn FileSystem> {
Arc::new(InMemoryFs::new())
}
#[tokio::test]
async fn test_init_creates_git_directory() {
let fs = create_test_fs().await;
let client = GitClient::new(GitConfig::new());
let result = client.init(&fs, Path::new("/repo")).await.unwrap();
assert!(result.contains("Initialized empty Git repository"));
assert!(fs.exists(Path::new("/repo/.git")).await.unwrap());
assert!(fs.exists(Path::new("/repo/.git/HEAD")).await.unwrap());
assert!(fs.exists(Path::new("/repo/.git/config")).await.unwrap());
assert!(fs.exists(Path::new("/repo/.git/objects")).await.unwrap());
assert!(fs.exists(Path::new("/repo/.git/refs")).await.unwrap());
}
#[tokio::test]
async fn test_init_reinitialize() {
let fs = create_test_fs().await;
let client = GitClient::new(GitConfig::new());
client.init(&fs, Path::new("/repo")).await.unwrap();
let result = client.init(&fs, Path::new("/repo")).await.unwrap();
assert!(result.contains("Reinitialized existing Git repository"));
}
#[tokio::test]
async fn test_config_get_set() {
let fs = create_test_fs().await;
let client = GitClient::new(GitConfig::new());
client.init(&fs, Path::new("/repo")).await.unwrap();
let name = client
.config_get(&fs, Path::new("/repo"), "user.name")
.await
.unwrap();
assert_eq!(name, Some("sandbox".to_string()));
client
.config_set(&fs, Path::new("/repo"), "user.name", "Test User")
.await
.unwrap();
let name = client
.config_get(&fs, Path::new("/repo"), "user.name")
.await
.unwrap();
assert_eq!(name, Some("Test User".to_string()));
}
#[tokio::test]
async fn test_add_and_commit() {
let fs = create_test_fs().await;
let client = GitClient::new(GitConfig::new());
fs.mkdir(Path::new("/repo"), true).await.unwrap();
client.init(&fs, Path::new("/repo")).await.unwrap();
fs.write_file(Path::new("/repo/test.txt"), b"hello")
.await
.unwrap();
client
.add(&fs, Path::new("/repo"), &["test.txt"])
.await
.unwrap();
let status = client.status(&fs, Path::new("/repo")).await.unwrap();
assert!(status.staged.contains(&"test.txt".to_string()));
let result = client
.commit(&fs, Path::new("/repo"), "Initial commit")
.await
.unwrap();
assert!(result.contains("[master"));
assert!(result.contains("Initial commit"));
let log = client.log(&fs, Path::new("/repo"), None).await.unwrap();
assert_eq!(log.len(), 1);
assert_eq!(log[0].message, "Initial commit");
}
#[tokio::test]
async fn test_status_untracked_files() {
let fs = create_test_fs().await;
let client = GitClient::new(GitConfig::new());
fs.mkdir(Path::new("/repo"), true).await.unwrap();
client.init(&fs, Path::new("/repo")).await.unwrap();
fs.write_file(Path::new("/repo/file1.txt"), b"content1")
.await
.unwrap();
fs.write_file(Path::new("/repo/file2.txt"), b"content2")
.await
.unwrap();
let status = client.status(&fs, Path::new("/repo")).await.unwrap();
assert!(status.untracked.contains(&"file1.txt".to_string()));
assert!(status.untracked.contains(&"file2.txt".to_string()));
assert!(status.staged.is_empty());
}
#[tokio::test]
async fn test_author_from_config() {
let fs = create_test_fs().await;
let client = GitClient::new(GitConfig::new().author("Custom Author", "custom@example.com"));
fs.mkdir(Path::new("/repo"), true).await.unwrap();
client.init(&fs, Path::new("/repo")).await.unwrap();
let name = client
.config_get(&fs, Path::new("/repo"), "user.name")
.await
.unwrap();
assert_eq!(name, Some("Custom Author".to_string()));
let email = client
.config_get(&fs, Path::new("/repo"), "user.email")
.await
.unwrap();
assert_eq!(email, Some("custom@example.com".to_string()));
}
#[test]
fn test_validate_ref_name_blocks_traversal() {
assert!(GitClient::validate_ref_name("../../config").is_err());
assert!(GitClient::validate_ref_name("..").is_err());
assert!(GitClient::validate_ref_name("foo/../bar").is_err());
}
#[test]
fn test_validate_ref_name_blocks_invalid() {
assert!(GitClient::validate_ref_name("").is_err());
assert!(GitClient::validate_ref_name(".hidden").is_err());
assert!(GitClient::validate_ref_name("branch.lock").is_err());
assert!(GitClient::validate_ref_name("-dash").is_err());
assert!(GitClient::validate_ref_name("has space").is_err());
assert!(GitClient::validate_ref_name("has\x00null").is_err());
}
#[test]
fn test_validate_ref_name_allows_valid() {
assert!(GitClient::validate_ref_name("main").is_ok());
assert!(GitClient::validate_ref_name("feature/branch").is_ok());
assert!(GitClient::validate_ref_name("fix-123").is_ok());
assert!(GitClient::validate_ref_name("v1.0").is_ok());
}
#[tokio::test]
async fn test_branch_create_rejects_traversal() {
let fs: Arc<dyn crate::fs::FileSystem> = Arc::new(crate::fs::InMemoryFs::new());
let client = GitClient::new(GitConfig::new());
let result = client
.branch_create(&fs, Path::new("/repo"), "../../config")
.await;
assert!(result.is_err(), "path traversal branch name should fail");
let err = result.unwrap_err().to_string();
assert!(
err.contains("path traversal"),
"error should mention path traversal, got: {err}"
);
}
}