From ad0f29826beddb1df9844216f50c53ab9cedb8cf Mon Sep 17 00:00:00 2001 From: kjuulh Date: Fri, 20 Mar 2026 13:02:49 +0100 Subject: [PATCH] feat: add project add command for cloning repos into existing projects --- crates/gitnow/src/commands/project.rs | 145 ++++++++++++++++++++++++++ 1 file changed, 145 insertions(+) diff --git a/crates/gitnow/src/commands/project.rs b/crates/gitnow/src/commands/project.rs index 4892090..0e3d266 100644 --- a/crates/gitnow/src/commands/project.rs +++ b/crates/gitnow/src/commands/project.rs @@ -30,6 +30,8 @@ pub struct ProjectCommand { enum ProjectSubcommand { /// Create a new project with selected repositories Create(ProjectCreateCommand), + /// Add repositories to an existing project + Add(ProjectAddCommand), /// Delete an existing project Delete(ProjectDeleteCommand), } @@ -49,6 +51,17 @@ pub struct ProjectCreateCommand { no_shell: bool, } +#[derive(clap::Parser)] +pub struct ProjectAddCommand { + /// Project name to add repositories to + #[arg()] + name: Option, + + /// Skip cache when fetching repositories + #[arg(long = "no-cache", default_value = "false")] + no_cache: bool, +} + #[derive(clap::Parser)] pub struct ProjectDeleteCommand { /// Project name to delete @@ -112,6 +125,7 @@ impl ProjectCommand { pub async fn execute(&mut self, app: &'static App) -> anyhow::Result<()> { match self.command.take() { Some(ProjectSubcommand::Create(mut create)) => create.execute(app).await, + Some(ProjectSubcommand::Add(mut add)) => add.execute(app).await, Some(ProjectSubcommand::Delete(mut delete)) => delete.execute(app).await, None => self.open_existing(app).await, } @@ -315,6 +329,137 @@ impl ProjectCreateCommand { } } +impl ProjectAddCommand { + async fn execute(&mut self, app: &'static App) -> anyhow::Result<()> { + let projects_dir = get_projects_dir(app); + let projects = list_existing_projects(&projects_dir)?; + + if projects.is_empty() { + anyhow::bail!( + "no projects found in {}. Use 'gitnow project create' to create one.", + projects_dir.display() + ); + } + + // Step 1: Select project + let project = match self.name.take() { + Some(name) => projects + .iter() + .find(|p| p.name == name) + .ok_or(anyhow::anyhow!("project '{}' not found", name))? + .clone(), + None => app + .interactive() + .interactive_search_items(&projects)? + .ok_or(anyhow::anyhow!("no project selected"))?, + }; + + // Step 2: Load repositories + let repositories = if !self.no_cache { + match app.cache().get().await? { + Some(repos) => repos, + None => { + eprintln!("fetching repositories..."); + let repositories = app.projects_list().get_projects().await?; + app.cache().update(&repositories).await?; + repositories + } + } + } else { + app.projects_list().get_projects().await? + }; + + // Step 3: Multi-select repositories + eprintln!("Select repositories to add (Tab to toggle, Enter to confirm):"); + let selected_repos = app + .interactive() + .interactive_multi_search(&repositories)?; + + if selected_repos.is_empty() { + anyhow::bail!("no repositories selected"); + } + + // Step 4: Clone each selected repository into the project directory + let clone_template = app + .config + .settings + .clone_command + .as_deref() + .unwrap_or(template_command::DEFAULT_CLONE_COMMAND); + + let concurrency_limit = Arc::new(tokio::sync::Semaphore::new(5)); + let mut handles = Vec::new(); + + for repo in &selected_repos { + let repo = repo.clone(); + let project_path = project.path.clone(); + let clone_template = clone_template.to_string(); + let concurrency = Arc::clone(&concurrency_limit); + let custom_command = app.custom_command(); + + let handle = tokio::spawn(async move { + let permit = concurrency.acquire().await?; + + let clone_path = project_path.join(&repo.repo_name); + + if clone_path.exists() { + eprintln!(" {} already exists, skipping", repo.repo_name); + drop(permit); + return Ok::<(), anyhow::Error>(()); + } + + eprintln!(" cloning {}...", repo.to_rel_path().display()); + + let path_str = clone_path.display().to_string(); + let context = HashMap::from([ + ("ssh_url", repo.ssh_url.as_str()), + ("path", path_str.as_str()), + ]); + + let output = + template_command::render_and_execute(&clone_template, context).await?; + + if !output.status.success() { + let stderr = std::str::from_utf8(&output.stderr).unwrap_or_default(); + anyhow::bail!("failed to clone {}: {}", repo.repo_name, stderr); + } + + custom_command + .execute_post_clone_command(&clone_path) + .await?; + + drop(permit); + Ok(()) + }); + + handles.push(handle); + } + + let results = futures::future::join_all(handles).await; + for res in results { + match res { + Ok(Ok(())) => {} + Ok(Err(e)) => { + tracing::error!("clone error: {}", e); + eprintln!("error: {}", e); + } + Err(e) => { + tracing::error!("task error: {}", e); + eprintln!("error: {}", e); + } + } + } + + eprintln!( + "added {} repositories to project '{}'", + selected_repos.len(), + project.name + ); + + Ok(()) + } +} + impl ProjectDeleteCommand { async fn execute(&mut self, app: &'static App) -> anyhow::Result<()> { let projects_dir = get_projects_dir(app);