vibe_workspace/repository/
create.rs1use anyhow::{Context, Result};
2use console::style;
3use std::path::PathBuf;
4use tokio::process::Command;
5
6use crate::workspace::Repository;
7use crate::workspace::WorkspaceManager;
8use crate::{display_println, utils::git::is_github_cli_available};
9
10#[derive(Debug, Clone)]
11pub struct GitHubUserInfo {
12 pub username: String,
13 pub organizations: Vec<GitHubOrganization>,
14}
15
16#[derive(Debug, Clone)]
17pub struct GitHubOrganization {
18 pub login: String,
19 pub name: Option<String>,
20}
21
22pub struct RepositoryCreator {
23 workspace_root: PathBuf,
24}
25
26impl RepositoryCreator {
27 pub fn new(workspace_root: PathBuf) -> Self {
28 Self { workspace_root }
29 }
30
31 pub async fn get_github_user_info(&self) -> Result<GitHubUserInfo> {
33 if !is_github_cli_available() {
34 anyhow::bail!("GitHub CLI is not available. Please install 'gh' command.");
35 }
36
37 let username = self.get_github_username().await?;
39
40 let organizations = self.get_github_organizations().await?;
42
43 Ok(GitHubUserInfo {
44 username,
45 organizations,
46 })
47 }
48
49 async fn get_github_username(&self) -> Result<String> {
50 let output = Command::new("gh")
51 .args(["api", "user", "--jq", ".login"])
52 .output()
53 .await
54 .context("Failed to get GitHub username")?;
55
56 if !output.status.success() {
57 let error_msg = String::from_utf8_lossy(&output.stderr);
58 anyhow::bail!("Failed to get GitHub username: {}", error_msg);
59 }
60
61 let username = String::from_utf8(output.stdout)
62 .context("Invalid UTF-8 in username response")?
63 .trim()
64 .to_string();
65
66 if username.is_empty() {
67 anyhow::bail!("No GitHub username found. Please authenticate with 'gh auth login'");
68 }
69
70 Ok(username)
71 }
72
73 async fn get_github_organizations(&self) -> Result<Vec<GitHubOrganization>> {
74 let output = Command::new("gh")
75 .args(["api", "user/orgs", "--jq", ".[].login"])
76 .output()
77 .await
78 .context("Failed to get GitHub organizations")?;
79
80 if !output.status.success() {
81 return Ok(Vec::new());
83 }
84
85 let orgs_output =
86 String::from_utf8(output.stdout).context("Invalid UTF-8 in organizations response")?;
87
88 let organizations = orgs_output
89 .lines()
90 .filter(|line| !line.trim().is_empty())
91 .map(|login| GitHubOrganization {
92 login: login.trim().to_string(),
93 name: None, })
95 .collect();
96
97 Ok(organizations)
98 }
99
100 pub async fn check_repository_availability(
102 &self,
103 owner: &str,
104 repo_name: &str,
105 ) -> Result<bool> {
106 if !is_github_cli_available() {
107 return Ok(true);
109 }
110
111 let output = Command::new("gh")
112 .args(["api", &format!("repos/{owner}/{repo_name}")])
113 .output()
114 .await
115 .context("Failed to check repository availability")?;
116
117 Ok(!output.status.success())
120 }
121
122 pub async fn create_local_repository(
124 &self,
125 owner: &str,
126 repo_name: &str,
127 workspace_manager: &mut WorkspaceManager,
128 ) -> Result<PathBuf> {
129 let repo_path = self.workspace_root.join(owner).join(repo_name);
131
132 if repo_path.exists() {
134 anyhow::bail!("Directory already exists: {}", repo_path.display());
135 }
136
137 tokio::fs::create_dir_all(&repo_path)
139 .await
140 .context("Failed to create repository directory")?;
141
142 display_println!(
143 "{} Created directory: {}",
144 style("📁").blue(),
145 style(repo_path.display()).cyan()
146 );
147
148 self.initialize_git_repository(&repo_path).await?;
150
151 self.apply_default_template(&repo_path, repo_name).await?;
153
154 self.create_initial_commit(&repo_path, repo_name).await?;
156
157 let repository_config = Repository {
159 name: repo_name.to_string(),
160 path: PathBuf::from(owner).join(repo_name),
161 url: Some(format!("https://github.com/{owner}/{repo_name}")),
162 branch: Some("main".to_string()),
163 apps: std::collections::HashMap::new(),
164 worktree_config: None,
165 };
166
167 workspace_manager.add_repository(repository_config).await?;
168
169 display_println!(
170 "{} Repository '{}' created successfully!",
171 style("✅").green().bold(),
172 style(repo_name).cyan()
173 );
174
175 Ok(repo_path)
176 }
177
178 async fn initialize_git_repository(&self, repo_path: &PathBuf) -> Result<()> {
179 let output = Command::new("git")
180 .args(["init"])
181 .current_dir(repo_path)
182 .output()
183 .await
184 .context("Failed to initialize git repository")?;
185
186 if !output.status.success() {
187 let error_msg = String::from_utf8_lossy(&output.stderr);
188 anyhow::bail!("Git init failed: {}", error_msg);
189 }
190
191 let _output = Command::new("git")
193 .args(["branch", "-M", "main"])
194 .current_dir(repo_path)
195 .output()
196 .await
197 .context("Failed to set default branch")?;
198
199 display_println!("{} Initialized git repository", style("📝").blue());
200
201 Ok(())
202 }
203
204 async fn apply_default_template(&self, repo_path: &PathBuf, repo_name: &str) -> Result<()> {
205 let readme_content = format!(
207 "# {repo_name}\n\nA new repository created with vibe-workspace.\n\n## Getting Started\n\nThis repository is ready for development. Add your code in the `src/` directory.\n\n## TODO\n\n- [ ] Choose your development framework\n- [ ] Set up your development environment\n- [ ] Add project-specific configuration\n- [ ] Update this README with project details\n"
208 );
209
210 tokio::fs::write(repo_path.join("README.md"), readme_content)
211 .await
212 .context("Failed to create README.md")?;
213
214 let gitignore_content = r#"# OS generated files
216.DS_Store
217.DS_Store?
218._*
219.Spotlight-V100
220.Trashes
221ehthumbs.db
222Thumbs.db
223
224# IDE files
225.vscode/
226.idea/
227*.swp
228*.swo
229*~
230
231# Logs
232logs
233*.log
234npm-debug.log*
235yarn-debug.log*
236yarn-error.log*
237
238# Runtime data
239pids
240*.pid
241*.seed
242*.pid.lock
243
244# Dependency directories
245node_modules/
246vendor/
247
248# Build outputs
249dist/
250build/
251target/
252*.o
253*.so
254*.dylib
255*.exe
256
257# Environment files
258.env
259.env.local
260.env.development.local
261.env.test.local
262.env.production.local
263"#;
264
265 tokio::fs::write(repo_path.join(".gitignore"), gitignore_content)
266 .await
267 .context("Failed to create .gitignore")?;
268
269 let src_dir = repo_path.join("src");
271 tokio::fs::create_dir_all(&src_dir)
272 .await
273 .context("Failed to create src directory")?;
274
275 let main_content = r#"// TODO: Add your main application code here
276// This is a placeholder file to get you started
277
278fn main() {
279 println!("Hello from your new repository!");
280
281 // TODO: Replace this with your actual application logic
282}
283"#;
284
285 tokio::fs::write(src_dir.join("main.rs"), main_content)
286 .await
287 .context("Failed to create main.rs")?;
288
289 let docs_dir = repo_path.join("docs");
291 tokio::fs::create_dir_all(&docs_dir)
292 .await
293 .context("Failed to create docs directory")?;
294
295 let todo_content = r#"# Development Setup TODOs
296
297This file contains setup hooks and next steps for your new repository.
298
299## Framework Setup
300
301Choose and set up your development framework:
302
303### Web Development
304- [ ] Initialize npm/yarn project: `npm init` or `yarn init`
305- [ ] Install React/Vue/Angular: `npm install react` etc.
306- [ ] Set up build tools (Vite, Webpack, etc.)
307
308### Backend Development
309- [ ] Initialize project: `cargo init`, `go mod init`, `npm init`, etc.
310- [ ] Set up database connections
311- [ ] Configure environment variables
312
313### Mobile Development
314- [ ] Initialize React Native: `npx react-native init`
315- [ ] Set up Flutter: `flutter create`
316- [ ] Configure platform-specific settings
317
318### Desktop Development
319- [ ] Set up Electron: `npm install electron`
320- [ ] Configure Tauri: `cargo install tauri-cli`
321- [ ] Set up native development environment
322
323## Development Environment
324
325- [ ] Configure your preferred development app (already done via vibe!)
326- [ ] Set up debugging configuration
327- [ ] Configure linting and formatting
328- [ ] Set up testing framework
329- [ ] Configure CI/CD pipeline
330
331## Next Steps
332
3331. Delete this file once you've completed the setup
3342. Update the main README.md with project-specific information
3353. Start building your application!
336
337## Deployment
338
339When ready to deploy:
340- [ ] Create GitHub repository: `gh repo create`
341- [ ] Set up hosting (Vercel, Netlify, Heroku, etc.)
342- [ ] Configure domain and SSL
343"#;
344
345 tokio::fs::write(docs_dir.join("TODO.md"), todo_content)
346 .await
347 .context("Failed to create TODO.md")?;
348
349 display_println!("{} Applied default template", style("📄").blue());
350
351 Ok(())
352 }
353
354 async fn create_initial_commit(&self, repo_path: &PathBuf, repo_name: &str) -> Result<()> {
355 let output = Command::new("git")
357 .args(["add", "."])
358 .current_dir(repo_path)
359 .output()
360 .await
361 .context("Failed to add files to git")?;
362
363 if !output.status.success() {
364 let error_msg = String::from_utf8_lossy(&output.stderr);
365 anyhow::bail!("Git add failed: {}", error_msg);
366 }
367
368 let commit_message = format!("Initial commit for {repo_name}");
370 let output = Command::new("git")
371 .args(["commit", "-m", &commit_message])
372 .current_dir(repo_path)
373 .output()
374 .await
375 .context("Failed to create initial commit")?;
376
377 if !output.status.success() {
378 let error_msg = String::from_utf8_lossy(&output.stderr);
379 anyhow::bail!("Git commit failed: {}", error_msg);
380 }
381
382 display_println!("{} Created initial commit", style("📝").blue());
383
384 Ok(())
385 }
386
387 pub fn validate_repository_name(&self, name: &str) -> Result<()> {
389 if name.is_empty() {
390 anyhow::bail!("Repository name cannot be empty");
391 }
392
393 if name.len() > 100 {
394 anyhow::bail!("Repository name is too long (max 100 characters)");
395 }
396
397 if !name
399 .chars()
400 .all(|c| c.is_alphanumeric() || c == '-' || c == '_' || c == '.')
401 {
402 anyhow::bail!("Repository name contains invalid characters. Use only letters, numbers, hyphens, underscores, and periods.");
403 }
404
405 if name.starts_with('.') || name.ends_with('.') {
406 anyhow::bail!("Repository name cannot start or end with a period");
407 }
408
409 if name.starts_with('-') || name.ends_with('-') {
410 anyhow::bail!("Repository name cannot start or end with a hyphen");
411 }
412
413 Ok(())
414 }
415}
416
417#[cfg(test)]
418mod tests {
419 use super::*;
420 use std::path::PathBuf;
421
422 #[test]
423 fn test_validate_repository_name() {
424 let creator = RepositoryCreator::new(PathBuf::from("/tmp"));
425
426 assert!(creator.validate_repository_name("my-repo").is_ok());
428 assert!(creator.validate_repository_name("my_repo").is_ok());
429 assert!(creator.validate_repository_name("MyRepo123").is_ok());
430 assert!(creator.validate_repository_name("repo.config").is_ok());
431
432 assert!(creator.validate_repository_name("").is_err());
434 assert!(creator.validate_repository_name(".hidden").is_err());
435 assert!(creator.validate_repository_name("repo.").is_err());
436 assert!(creator.validate_repository_name("-repo").is_err());
437 assert!(creator.validate_repository_name("repo-").is_err());
438 assert!(creator
439 .validate_repository_name("repo with spaces")
440 .is_err());
441 assert!(creator.validate_repository_name("repo@invalid").is_err());
442 }
443}