vibe_workspace/workspace/
install.rs1use anyhow::{Context, Result};
2use colored::*;
3use std::path::{Path, PathBuf};
4use tokio::process::Command;
5
6use crate::git::{GitConfig, GitError};
7use crate::utils::fs::expand_tilde;
8use crate::utils::git::is_git_available;
9use crate::workspace::config::Repository as ConfigRepository;
10
11pub struct RepositoryInstaller {
12 workspace_root: PathBuf,
13 git_config: GitConfig,
14}
15
16#[derive(Debug, Clone)]
17pub struct InstalledRepository {
18 pub repository: ConfigRepository,
19 pub path: PathBuf,
20 pub post_install_actions: Vec<PostInstallAction>,
21}
22
23#[derive(Debug, Clone)]
24pub enum PostInstallAction {
25 RunNpmInstall,
26 RunCargoCheck,
27 OpenInEditor(String),
28}
29
30impl RepositoryInstaller {
31 pub fn new(workspace_root: PathBuf, git_config: GitConfig) -> Self {
32 Self {
33 workspace_root: expand_tilde(&workspace_root),
34 git_config,
35 }
36 }
37
38 pub async fn install_from_url(&self, url: &str) -> Result<InstalledRepository> {
39 self.install_from_url_with_options(url, None, false, false)
40 .await
41 }
42
43 pub async fn install_from_url_with_options(
44 &self,
45 url: &str,
46 custom_path: Option<PathBuf>,
47 open_after_clone: bool,
48 run_install_commands: bool,
49 ) -> Result<InstalledRepository> {
50 if !is_git_available() {
51 anyhow::bail!("Git is not available on the system");
52 }
53
54 let (org, repo_name) = self.parse_git_url(url)?;
55 let target_path = if let Some(path) = custom_path {
56 expand_tilde(&path)
57 } else {
58 self.calculate_install_path(&org, &repo_name)
59 };
60
61 if target_path.exists() {
63 return Err(GitError::RepositoryExists { path: target_path }.into());
64 }
65
66 println!(
67 "{} Cloning {} to {}",
68 "📦".cyan(),
69 url.cyan().bold(),
70 target_path.display().to_string().green()
71 );
72
73 if let Some(parent) = target_path.parent() {
75 tokio::fs::create_dir_all(parent)
76 .await
77 .context("Failed to create parent directory")?;
78 }
79
80 self.clone_repository(url, &target_path).await?;
82
83 let installed_repo = self.create_repository_config(&org, &repo_name, url, &target_path)?;
85
86 let mut post_install_actions = Vec::new();
88
89 if run_install_commands {
90 if target_path.join("package.json").exists() {
92 post_install_actions.push(PostInstallAction::RunNpmInstall);
93 }
94 if target_path.join("Cargo.toml").exists() {
96 post_install_actions.push(PostInstallAction::RunCargoCheck);
97 }
98 }
99
100 if open_after_clone {
101 post_install_actions.push(PostInstallAction::OpenInEditor("vscode".to_string()));
102 }
103
104 println!("{} Successfully cloned repository", "✅".green());
105
106 Ok(InstalledRepository {
107 repository: installed_repo,
108 path: target_path,
109 post_install_actions,
110 })
111 }
112
113 fn parse_git_url(&self, url: &str) -> Result<(String, String)> {
114 let url = url.trim();
116
117 if url.starts_with("git@") {
119 let parts: Vec<&str> = url.split(':').collect();
120 if parts.len() != 2 {
121 return Err(GitError::InvalidUrl {
122 url: url.to_string(),
123 }
124 .into());
125 }
126
127 let path_parts: Vec<&str> = parts[1].trim_end_matches(".git").split('/').collect();
128
129 if path_parts.len() != 2 {
130 return Err(GitError::InvalidUrl {
131 url: url.to_string(),
132 }
133 .into());
134 }
135
136 return Ok((path_parts[0].to_string(), path_parts[1].to_string()));
137 }
138
139 if url.starts_with("https://") || url.starts_with("http://") {
141 let parsed_url = url::Url::parse(url).map_err(|_| GitError::InvalidUrl {
142 url: url.to_string(),
143 })?;
144
145 let path = parsed_url
146 .path()
147 .trim_start_matches('/')
148 .trim_end_matches(".git");
149 let path_parts: Vec<&str> = path.split('/').collect();
150
151 if path_parts.len() < 2 {
152 return Err(GitError::InvalidUrl {
153 url: url.to_string(),
154 }
155 .into());
156 }
157
158 let org = path_parts[path_parts.len() - 2].to_string();
161 let repo = path_parts[path_parts.len() - 1].to_string();
162
163 return Ok((org, repo));
164 }
165
166 let parts: Vec<&str> = url.split('/').collect();
168 if parts.len() == 2 {
169 return Ok((parts[0].to_string(), parts[1].to_string()));
170 }
171
172 Err(GitError::InvalidUrl {
173 url: url.to_string(),
174 }
175 .into())
176 }
177
178 fn calculate_install_path(&self, org: &str, repo: &str) -> PathBuf {
179 if self.git_config.standardize_paths {
180 self.workspace_root.join(org).join(repo)
181 } else {
182 self.workspace_root.join(repo)
183 }
184 }
185
186 async fn clone_repository(&self, url: &str, target_path: &Path) -> Result<()> {
187 let output = Command::new("git")
188 .args(["clone", url, target_path.to_str().unwrap()])
189 .output()
190 .await
191 .context("Failed to execute git clone")?;
192
193 if !output.status.success() {
194 let error_msg = String::from_utf8_lossy(&output.stderr);
195 return Err(GitError::CloneFailed {
196 message: error_msg.to_string(),
197 }
198 .into());
199 }
200
201 Ok(())
202 }
203
204 fn create_repository_config(
205 &self,
206 org: &str,
207 repo_name: &str,
208 url: &str,
209 path: &Path,
210 ) -> Result<ConfigRepository> {
211 use std::collections::HashMap;
212
213 Ok(ConfigRepository {
214 name: format!("{org}/{repo_name}"),
215 path: path.to_path_buf(),
216 url: Some(url.to_string()),
217 branch: None, apps: HashMap::new(),
219 worktree_config: None,
220 })
221 }
222
223 pub async fn execute_post_install_actions(
224 &self,
225 actions: &[PostInstallAction],
226 repo_path: &Path,
227 ) -> Result<()> {
228 for action in actions {
229 match action {
230 PostInstallAction::RunNpmInstall => {
231 println!("{} Running npm install...", "📦".cyan());
232 let output = Command::new("npm")
233 .arg("install")
234 .current_dir(repo_path)
235 .output()
236 .await?;
237
238 if !output.status.success() {
239 eprintln!("Warning: npm install failed");
240 }
241 }
242 PostInstallAction::RunCargoCheck => {
243 println!("{} Running cargo check...", "🦀".cyan());
244 let output = Command::new("cargo")
245 .arg("check")
246 .current_dir(repo_path)
247 .output()
248 .await?;
249
250 if !output.status.success() {
251 eprintln!("Warning: cargo check failed");
252 }
253 }
254 PostInstallAction::OpenInEditor(editor) => {
255 println!("{} Opening in {}...", "📝".cyan(), editor);
256 }
259 }
260 }
261 Ok(())
262 }
263}