From 681993c3799fc7b3114234da9ce274974bb961e0 Mon Sep 17 00:00:00 2001 From: kjuulh Date: Fri, 20 Mar 2026 13:23:47 +0100 Subject: [PATCH] refactor: eliminate code duplication across gitnow codebase - Unify PostCloneCommand/PostUpdateCommand into single CommandList enum - Add string_newtype! macro for config newtype wrappers - Extract load_repositories() helper replacing 4 duplicated cache-or-fetch patterns - Extract clone_repos_into() eliminating duplicated concurrent clone logic - Merge TemplateEntry/ProjectEntry into single DirEntry struct - Unify directory resolution and listing helpers in project command - Simplify RepositoryMatcher, remove unused StringExt trait --- crates/gitnow/src/cache.rs | 16 + crates/gitnow/src/commands/project.rs | 420 +++++++++---------------- crates/gitnow/src/commands/root.rs | 57 +--- crates/gitnow/src/commands/worktree.rs | 17 +- crates/gitnow/src/config.rs | 102 ++---- crates/gitnow/src/custom_command.rs | 96 +++--- 6 files changed, 249 insertions(+), 459 deletions(-) diff --git a/crates/gitnow/src/cache.rs b/crates/gitnow/src/cache.rs index 2095a8d..68fd29e 100644 --- a/crates/gitnow/src/cache.rs +++ b/crates/gitnow/src/cache.rs @@ -105,6 +105,22 @@ impl Cache { } } +/// Load repositories using the cache if available, otherwise fetch and update cache. +pub async fn load_repositories(app: &'static App, use_cache: bool) -> anyhow::Result> { + use crate::projects_list::ProjectsListApp; + + if use_cache { + if let Some(repos) = app.cache().get().await? { + return Ok(repos); + } + } + + tracing::info!("fetching repositories..."); + let repositories = app.projects_list().get_projects().await?; + app.cache().update(&repositories).await?; + Ok(repositories) +} + pub trait CacheApp { fn cache(&self) -> Cache; } diff --git a/crates/gitnow/src/commands/project.rs b/crates/gitnow/src/commands/project.rs index 9612b06..3e0fcd7 100644 --- a/crates/gitnow/src/commands/project.rs +++ b/crates/gitnow/src/commands/project.rs @@ -1,13 +1,12 @@ use std::collections::HashMap; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::sync::Arc; use crate::{ app::App, - cache::CacheApp, + cache::load_repositories, custom_command::CustomCommandApp, interactive::{InteractiveApp, Searchable}, - projects_list::ProjectsListApp, shell::ShellApp, template_command, }; @@ -77,55 +76,75 @@ pub struct ProjectDeleteCommand { force: bool, } -fn get_templates_dir(app: &'static App) -> PathBuf { - if let Some(ref project_settings) = app.config.settings.project { - if let Some(ref dir) = project_settings.templates_directory { - let path = PathBuf::from(dir); - if let Ok(stripped) = path.strip_prefix("~") { - let home = dirs::home_dir().unwrap_or_default(); - return home.join(stripped); - } - return path; - } - } - - let home = dirs::home_dir().unwrap_or_default(); - home.join(".gitnow").join("templates") -} +// --- Shared helpers --- +/// A named directory entry usable in interactive search. #[derive(Clone)] -struct TemplateEntry { +struct DirEntry { name: String, path: PathBuf, } -impl Searchable for TemplateEntry { +impl Searchable for DirEntry { fn display_label(&self) -> String { self.name.clone() } } -fn list_templates(templates_dir: &PathBuf) -> anyhow::Result> { - if !templates_dir.exists() { +/// Resolve a config directory path, expanding `~` to the home directory. +/// Falls back to `default` if the config value is `None`. +fn resolve_dir(configured: Option<&str>, default: &str) -> PathBuf { + if let Some(dir) = configured { + let path = PathBuf::from(dir); + if let Ok(stripped) = path.strip_prefix("~") { + return dirs::home_dir().unwrap_or_default().join(stripped); + } + return path; + } + dirs::home_dir().unwrap_or_default().join(default) +} + +fn get_projects_dir(app: &'static App) -> PathBuf { + let configured = app + .config + .settings + .project + .as_ref() + .and_then(|p| p.directory.as_deref()); + resolve_dir(configured, ".gitnow/projects") +} + +fn get_templates_dir(app: &'static App) -> PathBuf { + let configured = app + .config + .settings + .project + .as_ref() + .and_then(|p| p.templates_directory.as_deref()); + resolve_dir(configured, ".gitnow/templates") +} + +/// List subdirectories of `dir` as `DirEntry` items, sorted by name. +fn list_subdirectories(dir: &Path) -> anyhow::Result> { + if !dir.exists() { return Ok(Vec::new()); } - let mut templates = Vec::new(); - for entry in std::fs::read_dir(templates_dir)? { + let mut entries = Vec::new(); + for entry in std::fs::read_dir(dir)? { let entry = entry?; if entry.file_type()?.is_dir() { - let name = entry.file_name().to_string_lossy().to_string(); - templates.push(TemplateEntry { - name, + entries.push(DirEntry { + name: entry.file_name().to_string_lossy().to_string(), path: entry.path(), }); } } - templates.sort_by(|a, b| a.name.cmp(&b.name)); - Ok(templates) + entries.sort_by(|a, b| a.name.cmp(&b.name)); + Ok(entries) } -fn copy_dir_recursive(src: &std::path::Path, dst: &std::path::Path) -> anyhow::Result<()> { +fn copy_dir_recursive(src: &Path, dst: &Path) -> anyhow::Result<()> { std::fs::create_dir_all(dst)?; for entry in std::fs::read_dir(src)? { let entry = entry?; @@ -139,53 +158,102 @@ fn copy_dir_recursive(src: &std::path::Path, dst: &std::path::Path) -> anyhow::R Ok(()) } -fn get_projects_dir(app: &'static App) -> PathBuf { - if let Some(ref project_settings) = app.config.settings.project { - if let Some(ref dir) = project_settings.directory { - let path = PathBuf::from(dir); - if let Ok(stripped) = path.strip_prefix("~") { - let home = dirs::home_dir().unwrap_or_default(); - return home.join(stripped); +/// Clone selected repositories concurrently into `target_dir`. +async fn clone_repos_into( + app: &'static App, + repos: &[crate::git_provider::Repository], + target_dir: &Path, +) -> anyhow::Result<()> { + 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 repos { + let repo = repo.clone(); + let target_dir = target_dir.to_path_buf(); + 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 = target_dir.join(&repo.repo_name); + + if clone_path.exists() { + eprintln!(" {} already exists, skipping", repo.repo_name); + 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?; + + 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); } - return path; } } - let home = dirs::home_dir().unwrap_or_default(); - home.join(".gitnow").join("projects") + Ok(()) } -#[derive(Clone)] -struct ProjectEntry { - name: String, - path: PathBuf, -} - -impl Searchable for ProjectEntry { - fn display_label(&self) -> String { - self.name.clone() +/// Helper to select an existing project, either by name or interactively. +fn select_project( + app: &'static App, + name: Option, + projects: &[DirEntry], +) -> anyhow::Result { + match name { + Some(name) => projects + .iter() + .find(|p| p.name == name) + .ok_or_else(|| anyhow::anyhow!("project '{}' not found", name)) + .cloned(), + None => app + .interactive() + .interactive_search_items(projects)? + .ok_or_else(|| anyhow::anyhow!("no project selected")), } } -fn list_existing_projects(projects_dir: &PathBuf) -> anyhow::Result> { - if !projects_dir.exists() { - return Ok(Vec::new()); - } - - let mut projects = Vec::new(); - for entry in std::fs::read_dir(projects_dir)? { - let entry = entry?; - if entry.file_type()?.is_dir() { - let name = entry.file_name().to_string_lossy().to_string(); - projects.push(ProjectEntry { - name, - path: entry.path(), - }); - } - } - projects.sort_by(|a, b| a.name.cmp(&b.name)); - Ok(projects) -} +// --- Command implementations --- impl ProjectCommand { pub async fn execute(&mut self, app: &'static App) -> anyhow::Result<()> { @@ -199,7 +267,7 @@ impl ProjectCommand { async fn open_existing(&self, app: &'static App) -> anyhow::Result<()> { let projects_dir = get_projects_dir(app); - let projects = list_existing_projects(&projects_dir)?; + let projects = list_subdirectories(&projects_dir)?; if projects.is_empty() { anyhow::bail!( @@ -214,7 +282,6 @@ impl ProjectCommand { .iter() .find(|p| p.name.contains(needle.as_str())) .or_else(|| { - // fuzzy fallback projects.iter().find(|p| { p.name .to_lowercase() @@ -246,7 +313,6 @@ impl ProjectCommand { impl ProjectCreateCommand { async fn execute(&mut self, app: &'static App) -> anyhow::Result<()> { - // Step 1: Get project name let name = match self.name.take() { Some(n) => n, None => { @@ -261,7 +327,6 @@ impl ProjectCreateCommand { } }; - // Sanitize project name for use as directory let dir_name = name .replace(' ', "-") .replace('/', "-") @@ -278,22 +343,8 @@ impl ProjectCreateCommand { ); } - // 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? - }; + let repositories = load_repositories(app, !self.no_cache).await?; - // Step 3: Multi-select repositories eprintln!("Select repositories (Tab to toggle, Enter to confirm):"); let selected_repos = app .interactive() @@ -303,85 +354,15 @@ impl ProjectCreateCommand { anyhow::bail!("no repositories selected"); } - // Step 4: Create project directory tokio::fs::create_dir_all(&project_path).await?; - // Step 5: 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); + clone_repos_into(app, &selected_repos, &project_path).await?; - 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); - } - } - } - - // Step 6: Apply template if requested + // Apply template if requested let templates_dir = get_templates_dir(app); let template = match self.template.take() { Some(name) => { - let templates = list_templates(&templates_dir)?; + let templates = list_subdirectories(&templates_dir)?; Some( templates .into_iter() @@ -396,7 +377,7 @@ impl ProjectCreateCommand { ) } None => { - let templates = list_templates(&templates_dir)?; + let templates = list_subdirectories(&templates_dir)?; if !templates.is_empty() { eprintln!("Select a project template (Esc to skip):"); app.interactive().interactive_search_items(&templates)? @@ -418,7 +399,6 @@ impl ProjectCreateCommand { selected_repos.len() ); - // Step 6: Enter shell or print path if !self.no_shell { app.shell().spawn_shell_at(&project_path).await?; } else { @@ -432,7 +412,7 @@ 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)?; + let projects = list_subdirectories(&projects_dir)?; if projects.is_empty() { anyhow::bail!( @@ -441,35 +421,10 @@ impl ProjectAddCommand { ); } - // 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"))?, - }; + let project = select_project(app, self.name.take(), &projects)?; - // 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? - }; + let repositories = load_repositories(app, !self.no_cache).await?; - // Step 3: Multi-select repositories eprintln!("Select repositories to add (Tab to toggle, Enter to confirm):"); let selected_repos = app .interactive() @@ -479,76 +434,7 @@ impl ProjectAddCommand { 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); - } - } - } + clone_repos_into(app, &selected_repos, &project.path).await?; eprintln!( "added {} repositories to project '{}'", @@ -563,23 +449,13 @@ impl ProjectAddCommand { impl ProjectDeleteCommand { async fn execute(&mut self, app: &'static App) -> anyhow::Result<()> { let projects_dir = get_projects_dir(app); - let projects = list_existing_projects(&projects_dir)?; + let projects = list_subdirectories(&projects_dir)?; if projects.is_empty() { anyhow::bail!("no projects found in {}", projects_dir.display()); } - 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"))?, - }; + let project = select_project(app, self.name.take(), &projects)?; if !self.force { eprint!( diff --git a/crates/gitnow/src/commands/root.rs b/crates/gitnow/src/commands/root.rs index fa5434f..3a8a0c9 100644 --- a/crates/gitnow/src/commands/root.rs +++ b/crates/gitnow/src/commands/root.rs @@ -2,7 +2,7 @@ use std::{collections::BTreeMap, io::IsTerminal}; use crate::{ app::App, - cache::CacheApp, + cache::{load_repositories, CacheApp}, components::inline_command::InlineCommand, custom_command::CustomCommandApp, fuzzy_matcher::{FuzzyMatcher, FuzzyMatcherApp}, @@ -34,29 +34,13 @@ impl RootCommand { ) -> anyhow::Result<()> { tracing::debug!("executing"); - let repositories = if !force_cache_update { - if cache { - match self.app.cache().get().await? { - Some(repos) => repos, - None => { - tracing::info!("finding repositories..."); - let repositories = self.app.projects_list().get_projects().await?; - - self.app.cache().update(&repositories).await?; - - repositories - } - } - } else { - self.app.projects_list().get_projects().await? - } - } else { + let repositories = if force_cache_update { tracing::info!("forcing cache update..."); let repositories = self.app.projects_list().get_projects().await?; - self.app.cache().update(&repositories).await?; - repositories + } else { + load_repositories(self.app, cache).await? }; let repo = match search { @@ -149,42 +133,21 @@ impl RootCommand { } } -trait StringExt { - fn as_str_vec(&self) -> Vec<&str>; -} - -impl StringExt for Vec { - fn as_str_vec(&self) -> Vec<&str> { - self.iter().map(|r| r.as_ref()).collect() - } -} - -impl StringExt for Vec<&String> { - fn as_str_vec(&self) -> Vec<&str> { - self.iter().map(|r| r.as_ref()).collect() - } -} - pub trait RepositoryMatcher { fn match_repositories(&self, pattern: &str, repositories: &[Repository]) -> Vec; } impl RepositoryMatcher for FuzzyMatcher { fn match_repositories(&self, pattern: &str, repositories: &[Repository]) -> Vec { - let haystack = repositories + let haystack: BTreeMap = repositories .iter() .map(|r| (r.to_rel_path().display().to_string(), r)) - .collect::>(); - let haystack_keys = haystack.keys().collect::>(); - let haystack_keys = haystack_keys.as_str_vec(); + .collect(); + let keys: Vec<&str> = haystack.keys().map(|s| s.as_str()).collect(); - let res = self.match_pattern(pattern, &haystack_keys); - - let matched_repos = res + self.match_pattern(pattern, &keys) .into_iter() - .filter_map(|repo_key| haystack.get(repo_key).map(|r| (*r).to_owned())) - .collect::>(); - - matched_repos + .filter_map(|key| haystack.get(key).map(|r| (*r).to_owned())) + .collect() } } diff --git a/crates/gitnow/src/commands/worktree.rs b/crates/gitnow/src/commands/worktree.rs index 254aaaa..bf860c5 100644 --- a/crates/gitnow/src/commands/worktree.rs +++ b/crates/gitnow/src/commands/worktree.rs @@ -2,11 +2,10 @@ use std::io::IsTerminal; use crate::{ app::App, - cache::CacheApp, + cache::load_repositories, components::inline_command::InlineCommand, fuzzy_matcher::FuzzyMatcherApp, interactive::{InteractiveApp, StringItem}, - projects_list::ProjectsListApp, shell::ShellApp, worktree::{sanitize_branch_name, WorktreeApp}, }; @@ -35,19 +34,7 @@ pub struct WorktreeCommand { impl WorktreeCommand { pub async fn execute(&mut self, app: &'static App) -> anyhow::Result<()> { // Step 1: Load repositories - let repositories = if !self.no_cache { - match app.cache().get().await? { - Some(repos) => repos, - None => { - tracing::info!("finding repositories..."); - let repositories = app.projects_list().get_projects().await?; - app.cache().update(&repositories).await?; - repositories - } - } - } else { - app.projects_list().get_projects().await? - }; + let repositories = load_repositories(app, !self.no_cache).await?; // Step 2: Select repository let repo = match &self.search { diff --git a/crates/gitnow/src/config.rs b/crates/gitnow/src/config.rs index 6d89099..f57a556 100644 --- a/crates/gitnow/src/config.rs +++ b/crates/gitnow/src/config.rs @@ -63,37 +63,26 @@ pub struct WorktreeSettings { pub list_branches_command: Option, } +/// A list of shell commands that can be specified as a single string or an array in TOML. #[derive(Debug, Serialize, Deserialize, PartialEq, Clone)] #[serde(untagged)] -pub enum PostUpdateCommand { +pub enum CommandList { Single(String), Multiple(Vec), } -impl PostUpdateCommand { +impl CommandList { pub fn get_commands(&self) -> Vec { match self.clone() { - PostUpdateCommand::Single(item) => vec![item], - PostUpdateCommand::Multiple(items) => items, + CommandList::Single(item) => vec![item], + CommandList::Multiple(items) => items, } } } -#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)] -#[serde(untagged)] -pub enum PostCloneCommand { - Single(String), - Multiple(Vec), -} - -impl PostCloneCommand { - pub fn get_commands(&self) -> Vec { - match self.clone() { - PostCloneCommand::Single(item) => vec![item], - PostCloneCommand::Multiple(items) => items, - } - } -} +/// Backwards-compatible type aliases. +pub type PostCloneCommand = CommandList; +pub type PostUpdateCommand = CommandList; #[derive(Debug, Default, Serialize, Deserialize, PartialEq, Clone)] pub struct Projects { @@ -229,35 +218,28 @@ pub struct GitHub { pub organisations: Vec, } -#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)] -pub struct GitHubUser(String); +/// Generates a newtype wrapper around `String` with `From` impls for owned and borrowed access. +macro_rules! string_newtype { + ($name:ident) => { + #[derive(Debug, Serialize, Deserialize, PartialEq, Clone)] + pub struct $name(String); -impl From for String { - fn from(value: GitHubUser) -> Self { - value.0 - } + impl From<$name> for String { + fn from(value: $name) -> Self { + value.0 + } + } + + impl<'a> From<&'a $name> for &'a str { + fn from(value: &'a $name) -> Self { + value.0.as_str() + } + } + }; } -impl<'a> From<&'a GitHubUser> for &'a str { - fn from(value: &'a GitHubUser) -> Self { - value.0.as_str() - } -} - -#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)] -pub struct GitHubOrganisation(String); - -impl From for String { - fn from(value: GitHubOrganisation) -> Self { - value.0 - } -} - -impl<'a> From<&'a GitHubOrganisation> for &'a str { - fn from(value: &'a GitHubOrganisation) -> Self { - value.0.as_str() - } -} +string_newtype!(GitHubUser); +string_newtype!(GitHubOrganisation); #[derive(Debug, Serialize, Deserialize, PartialEq, Clone)] pub struct Gitea { @@ -289,34 +271,8 @@ pub enum GitHubAccessToken { Env { env: String }, } -#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)] -pub struct GiteaUser(String); - -impl From for String { - fn from(value: GiteaUser) -> Self { - value.0 - } -} - -impl<'a> From<&'a GiteaUser> for &'a str { - fn from(value: &'a GiteaUser) -> Self { - value.0.as_str() - } -} - -#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)] -pub struct GiteaOrganisation(String); -impl From for String { - fn from(value: GiteaOrganisation) -> Self { - value.0 - } -} - -impl<'a> From<&'a GiteaOrganisation> for &'a str { - fn from(value: &'a GiteaOrganisation) -> Self { - value.0.as_str() - } -} +string_newtype!(GiteaUser); +string_newtype!(GiteaOrganisation); impl Config { pub async fn from_file(file_path: &Path) -> anyhow::Result { diff --git a/crates/gitnow/src/custom_command.rs b/crates/gitnow/src/custom_command.rs index 36d6e0d..00ac07c 100644 --- a/crates/gitnow/src/custom_command.rs +++ b/crates/gitnow/src/custom_command.rs @@ -1,73 +1,65 @@ use std::path::Path; -use crate::{app::App, config::Config}; +use crate::{app::App, config::CommandList}; pub struct CustomCommand { - config: Config, + post_clone: Option, + post_update: Option, } impl CustomCommand { - pub fn new(config: Config) -> Self { - Self { config } + pub fn new(app: &App) -> Self { + Self { + post_clone: app.config.settings.post_clone_command.clone(), + post_update: app.config.settings.post_update_command.clone(), + } } - pub async fn execute_post_clone_command(&self, path: &Path) -> anyhow::Result<()> { - if let Some(post_clone_command) = &self.config.settings.post_clone_command { - for command in post_clone_command.get_commands() { - let command_parts = command.split(' ').collect::>(); - let Some((first, rest)) = command_parts.split_first() else { - return Ok(()); - }; + async fn execute_commands( + commands: &CommandList, + path: &Path, + label: &str, + ) -> anyhow::Result<()> { + for command in commands.get_commands() { + let command_parts = command.split(' ').collect::>(); + let Some((first, rest)) = command_parts.split_first() else { + return Ok(()); + }; - let mut cmd = tokio::process::Command::new(first); - cmd.args(rest).current_dir(path); + let mut cmd = tokio::process::Command::new(first); + cmd.args(rest).current_dir(path); - eprintln!("running command: {}", command); + eprintln!("running command: {}", command); - tracing::info!( - path = path.display().to_string(), - cmd = command, - "running custom post clone command" - ); - let output = cmd.output().await?; - let stdout = std::str::from_utf8(&output.stdout)?; - tracing::info!( - stdout = stdout, - "finished running custom post clone command" - ); - } + tracing::info!( + path = path.display().to_string(), + cmd = command, + "running custom {} command", + label + ); + let output = cmd.output().await?; + let stdout = std::str::from_utf8(&output.stdout)?; + tracing::info!( + stdout = stdout, + "finished running custom {} command", + label + ); } Ok(()) } - pub async fn execute_post_update_command(&self, path: &Path) -> anyhow::Result<()> { - if let Some(post_update_command) = &self.config.settings.post_update_command { - for command in post_update_command.get_commands() { - let command_parts = command.split(' ').collect::>(); - let Some((first, rest)) = command_parts.split_first() else { - return Ok(()); - }; - - let mut cmd = tokio::process::Command::new(first); - cmd.args(rest).current_dir(path); - - eprintln!("running command: {}", command); - - tracing::info!( - path = path.display().to_string(), - cmd = command, - "running custom post update command" - ); - let output = cmd.output().await?; - let stdout = std::str::from_utf8(&output.stdout)?; - tracing::info!( - stdout = stdout, - "finished running custom post update command" - ); - } + pub async fn execute_post_clone_command(&self, path: &Path) -> anyhow::Result<()> { + if let Some(ref cmds) = self.post_clone { + Self::execute_commands(cmds, path, "post clone").await?; } + Ok(()) + } + pub async fn execute_post_update_command(&self, path: &Path) -> anyhow::Result<()> { + if let Some(ref cmds) = self.post_update { + Self::execute_commands(cmds, path, "post update").await?; + } Ok(()) } } @@ -78,6 +70,6 @@ pub trait CustomCommandApp { impl CustomCommandApp for App { fn custom_command(&self) -> CustomCommand { - CustomCommand::new(self.config.clone()) + CustomCommand::new(self) } }