use anyhow::{anyhow, bail, Context, Result};
use clap::Args;
use colored::*;
use git2::Repository;
use qdrant_client::Qdrant;
use std::{fs, path::PathBuf, sync::Arc, collections::HashMap};
use crate::config::{self, AppConfig};
use crate::cli::repo_commands::helpers;
#[derive(Args, Debug)]
#[derive(Clone)]
pub struct AddRepoArgs {
#[arg(short = 'p', long, value_parser)]
pub local_path: Option<PathBuf>,
#[clap(long, value_parser)]
pub url: String,
#[arg(short, long)]
pub name: Option<String>,
#[arg(short, long)]
pub branch: Option<String>,
#[arg(short = 'r', long)]
pub remote: Option<String>,
#[arg(long)]
pub ssh_key: Option<PathBuf>,
#[arg(long, requires = "ssh_key")] pub ssh_passphrase: Option<String>,
}
pub async fn handle_repo_add(
args: AddRepoArgs,
config: &mut AppConfig,
client: Arc<Qdrant>,
override_path: Option<&PathBuf>,
) -> Result<()> {
let repo_name = match args.name {
Some(name) => name,
None => PathBuf::from(&args.url)
.file_stem()
.and_then(|s| s.to_str())
.map(|s| s.trim_end_matches(".git").to_string())
.ok_or_else(|| anyhow!("Could not derive repository name from URL"))?,
};
if config.repositories.iter().any(|r| r.name == repo_name) {
bail!("Repository '{}' already exists.", repo_name);
}
let repo_base_path = config::get_repo_base_path()?;
fs::create_dir_all(&repo_base_path)
.with_context(|| format!("Failed to create repository base directory at {}", repo_base_path.display()))?;
let local_path = args.local_path.unwrap_or(repo_base_path.join(&repo_name));
let repo = if local_path.exists() {
println!(
"{}",
format!(
"Local directory '{}' already exists. Assuming it's the intended clone. Skipping clone.",
local_path.display()
).yellow()
);
Repository::open(&local_path)
.with_context(|| format!("Failed to open existing repository at {}", local_path.display()))?
} else {
println!("{}",
format!("\nSTEP 1/2: Cloning repository '{}' from {}", repo_name, args.url).bold().cyan()
);
fs::create_dir_all(&local_path)
.with_context(|| format!("Failed to create directory at {}", local_path.display()))?;
let mut cmd = std::process::Command::new("git");
cmd.arg("clone")
.arg(&args.url)
.arg(&local_path);
if let Some(ssh_key) = &args.ssh_key {
let ssh_cmd = if let Some(_passphrase) = &args.ssh_passphrase {
format!("ssh -i {} -o IdentitiesOnly=yes", ssh_key.display())
} else {
format!("ssh -i {} -o IdentitiesOnly=yes", ssh_key.display())
};
cmd.env("GIT_SSH_COMMAND", ssh_cmd);
println!("Using SSH key: {}", ssh_key.display());
}
let status = cmd.status()
.with_context(|| format!("Failed to execute git clone command"))?;
if !status.success() {
return Err(anyhow!("Git clone command failed with exit code: {}", status));
}
println!("\nRepository cloned successfully to {}", local_path.display());
Repository::open(&local_path)
.with_context(|| format!("Failed to open newly cloned repository at {}", local_path.display()))?
};
let initial_branch_name = match args.branch {
Some(branch_name) => branch_name,
None => {
let head_ref = repo.find_reference("HEAD")?;
let head_ref_resolved = head_ref.resolve()?;
head_ref_resolved.shorthand()
.ok_or_else(|| anyhow!("Could not determine default branch name from HEAD"))?
.to_string()
}
};
println!("Default/Initial branch detected: {}", initial_branch_name.cyan());
println!("\n{}",
format!("STEP 2/2: Setting up vector database infrastructure for '{}'", repo_name).bold().cyan()
);
let collection_name = helpers::get_collection_name(&repo_name);
println!("Ensuring Qdrant collection '{}' exists...", collection_name.cyan());
helpers::ensure_repository_collection_exists(&client, &collection_name).await?;
println!("Qdrant collection ensured.");
let new_repo_config = config::RepositoryConfig {
name: repo_name.clone(),
url: args.url.clone(),
local_path: local_path.clone(),
default_branch: initial_branch_name.clone(),
tracked_branches: vec![initial_branch_name.clone()],
active_branch: Some(initial_branch_name.clone()),
remote_name: Some(args.remote.unwrap_or_else(|| "origin".to_string())),
ssh_key_path: args.ssh_key.clone(),
ssh_key_passphrase: args.ssh_passphrase.clone(),
last_synced_commits: HashMap::new(),
indexed_languages: None,
};
config.repositories.push(new_repo_config);
config.active_repository = Some(repo_name.clone());
config::save_config(config, override_path)?;
println!("Set '{}' as the active repository.", repo_name.cyan());
println!("{}", "Successfully added repository configuration.".green());
println!("\n{}", "=".repeat(80).yellow());
println!("{}", "Repository added successfully! What's next?".bold().green());
println!("{}", "=".repeat(80).yellow());
println!("{}", format!("1. Repository '{}' has been cloned and configured", repo_name).bold());
println!("{}", "2. To make the code searchable, you need to index it:".bold());
println!("\n Run this command to index the repository:\n");
println!(" {}", format!("vectordb-cli repo sync {}", repo_name).cyan().bold());
println!("\n Note: Indexing large repositories may take several minutes to complete.");
println!(" For very large repos, this could take 10-20 minutes or more with GPU.");
println!("{}", "=".repeat(80).yellow());
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::AppConfig;
use tempfile::tempdir;
fn setup_test_config() -> (AppConfig, tempfile::TempDir) {
let temp_dir = tempdir().unwrap();
let config = AppConfig {
repositories: Vec::new(),
active_repository: None,
qdrant_url: "http://localhost:6334".to_string(),
onnx_model_path: Some("/fake/path/model.onnx".to_string()),
onnx_tokenizer_path: Some("/fake/path/tokenizer".to_string()),
};
(config, temp_dir)
}
#[test]
fn test_progress_calculation_uses_minimum() {
let test_cases = [
(100, 50, 50), (50, 100, 50), (75, 75, 75), (0, 0, 0), (1000, 0, 0), (0, 1000, 0), ];
for (received, indexed, expected) in test_cases {
let progress = indexed.min(received);
assert_eq!(progress, expected,
"Progress calculation incorrect for received={}, indexed={}",
received, indexed);
}
}
#[tokio::test]
async fn test_handle_repo_add_existing_path() {
let client = Arc::new(qdrant_client::Qdrant::from_url("http://localhost:6334").build().unwrap());
let (mut config, temp_dir) = setup_test_config();
let config_path = temp_dir.path().join("config.toml");
let repo_path = temp_dir.path().join("test-repo");
std::fs::create_dir_all(&repo_path).unwrap();
std::fs::create_dir_all(repo_path.join(".git")).unwrap();
std::fs::create_dir_all(repo_path.join(".git/refs/heads")).unwrap();
std::fs::create_dir_all(repo_path.join(".git/objects")).unwrap();
std::fs::write(repo_path.join(".git/HEAD"), "ref: refs/heads/main").unwrap();
let args = AddRepoArgs {
local_path: Some(repo_path.clone()),
url: "https://example.com/test-repo.git".to_string(),
name: Some("test-repo".to_string()),
branch: Some("main".to_string()), remote: None,
ssh_key: None,
ssh_passphrase: None,
};
let result = handle_repo_add(
args,
&mut config,
client,
Some(&config_path),
).await;
if result.is_ok() {
assert_eq!(config.repositories.len(), 1, "Repository should be added to config");
assert_eq!(config.repositories[0].name, "test-repo", "Repository name should match");
assert_eq!(config.active_repository, Some("test-repo".to_string()),
"Repository should be set as active");
}
}
#[test]
fn test_config_with_empty_repo_list() {
let mut config = AppConfig {
repositories: Vec::new(),
active_repository: None,
qdrant_url: "http://localhost:6334".to_string(),
onnx_model_path: None,
onnx_tokenizer_path: None,
};
assert_eq!(config.repositories.len(), 0, "Should start with no repositories");
assert_eq!(config.active_repository, None, "Should have no active repository");
config.repositories.push(crate::config::RepositoryConfig {
name: "test-repo".to_string(),
url: "https://example.com/test-repo.git".to_string(),
local_path: PathBuf::from("/tmp/test-repo"),
default_branch: "main".to_string(),
tracked_branches: vec!["main".to_string()],
active_branch: Some("main".to_string()),
remote_name: Some("origin".to_string()),
ssh_key_path: None,
ssh_key_passphrase: None,
last_synced_commits: HashMap::new(),
indexed_languages: None,
});
config.active_repository = Some("test-repo".to_string());
assert_eq!(config.repositories.len(), 1, "Should have one repository");
assert_eq!(config.repositories[0].name, "test-repo", "Repository name should match");
assert_eq!(config.active_repository, Some("test-repo".to_string()),
"Repository should be set as active");
}
}