Skip to main content

vibe_workspace/repository/
create.rs

1use 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    /// Get GitHub user information including organizations
32    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        // Get username
38        let username = self.get_github_username().await?;
39
40        // Get organizations
41        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            // Organizations query might fail if user has no orgs, which is fine
82            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, // We could fetch names separately if needed
94            })
95            .collect();
96
97        Ok(organizations)
98    }
99
100    /// Check if a repository name is available on GitHub for the given owner
101    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            // If GitHub CLI is not available, we can't check, so assume it's available
108            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        // If the repository exists, the command will succeed
118        // If it doesn't exist, it will fail with 404
119        Ok(!output.status.success())
120    }
121
122    /// Create a new local repository with the given name and structure
123    pub async fn create_local_repository(
124        &self,
125        owner: &str,
126        repo_name: &str,
127        workspace_manager: &mut WorkspaceManager,
128    ) -> Result<PathBuf> {
129        // Create the repository path structure: workspace_root/owner/repo_name
130        let repo_path = self.workspace_root.join(owner).join(repo_name);
131
132        // Check if directory already exists
133        if repo_path.exists() {
134            anyhow::bail!("Directory already exists: {}", repo_path.display());
135        }
136
137        // Create the directory structure
138        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        // Initialize git repository
149        self.initialize_git_repository(&repo_path).await?;
150
151        // Apply default template
152        self.apply_default_template(&repo_path, repo_name).await?;
153
154        // Create initial commit
155        self.create_initial_commit(&repo_path, repo_name).await?;
156
157        // Add to workspace configuration
158        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        // Set default branch to main
192        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        // Create README.md
206        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        // Create basic .gitignore
215        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        // Create src directory with placeholder
270        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        // Create docs directory with TODO
290        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        // Add all files
356        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        // Create initial commit
369        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    /// Validate repository name (basic validation)
388    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        // Basic character validation (GitHub allows alphanumeric, hyphens, underscores, periods)
398        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        // Valid names
427        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        // Invalid names
433        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}