refactor: eliminate code duplication across gitnow codebase
Some checks failed
continuous-integration/drone/push Build encountered an error

- 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
This commit is contained in:
2026-03-20 13:23:47 +01:00
parent bebb55e873
commit 681993c379
6 changed files with 249 additions and 459 deletions

View File

@@ -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<Vec<Repository>> {
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 { pub trait CacheApp {
fn cache(&self) -> Cache; fn cache(&self) -> Cache;
} }

View File

@@ -1,13 +1,12 @@
use std::collections::HashMap; use std::collections::HashMap;
use std::path::PathBuf; use std::path::{Path, PathBuf};
use std::sync::Arc; use std::sync::Arc;
use crate::{ use crate::{
app::App, app::App,
cache::CacheApp, cache::load_repositories,
custom_command::CustomCommandApp, custom_command::CustomCommandApp,
interactive::{InteractiveApp, Searchable}, interactive::{InteractiveApp, Searchable},
projects_list::ProjectsListApp,
shell::ShellApp, shell::ShellApp,
template_command, template_command,
}; };
@@ -77,55 +76,75 @@ pub struct ProjectDeleteCommand {
force: bool, force: bool,
} }
fn get_templates_dir(app: &'static App) -> PathBuf { // --- Shared helpers ---
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")
}
/// A named directory entry usable in interactive search.
#[derive(Clone)] #[derive(Clone)]
struct TemplateEntry { struct DirEntry {
name: String, name: String,
path: PathBuf, path: PathBuf,
} }
impl Searchable for TemplateEntry { impl Searchable for DirEntry {
fn display_label(&self) -> String { fn display_label(&self) -> String {
self.name.clone() self.name.clone()
} }
} }
fn list_templates(templates_dir: &PathBuf) -> anyhow::Result<Vec<TemplateEntry>> { /// Resolve a config directory path, expanding `~` to the home directory.
if !templates_dir.exists() { /// 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<Vec<DirEntry>> {
if !dir.exists() {
return Ok(Vec::new()); return Ok(Vec::new());
} }
let mut templates = Vec::new(); let mut entries = Vec::new();
for entry in std::fs::read_dir(templates_dir)? { for entry in std::fs::read_dir(dir)? {
let entry = entry?; let entry = entry?;
if entry.file_type()?.is_dir() { if entry.file_type()?.is_dir() {
let name = entry.file_name().to_string_lossy().to_string(); entries.push(DirEntry {
templates.push(TemplateEntry { name: entry.file_name().to_string_lossy().to_string(),
name,
path: entry.path(), path: entry.path(),
}); });
} }
} }
templates.sort_by(|a, b| a.name.cmp(&b.name)); entries.sort_by(|a, b| a.name.cmp(&b.name));
Ok(templates) 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)?; std::fs::create_dir_all(dst)?;
for entry in std::fs::read_dir(src)? { for entry in std::fs::read_dir(src)? {
let entry = entry?; let entry = entry?;
@@ -139,53 +158,102 @@ fn copy_dir_recursive(src: &std::path::Path, dst: &std::path::Path) -> anyhow::R
Ok(()) Ok(())
} }
fn get_projects_dir(app: &'static App) -> PathBuf { /// Clone selected repositories concurrently into `target_dir`.
if let Some(ref project_settings) = app.config.settings.project { async fn clone_repos_into(
if let Some(ref dir) = project_settings.directory { app: &'static App,
let path = PathBuf::from(dir); repos: &[crate::git_provider::Repository],
if let Ok(stripped) = path.strip_prefix("~") { target_dir: &Path,
let home = dirs::home_dir().unwrap_or_default(); ) -> anyhow::Result<()> {
return home.join(stripped); 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(); Ok(())
home.join(".gitnow").join("projects")
} }
#[derive(Clone)] /// Helper to select an existing project, either by name or interactively.
struct ProjectEntry { fn select_project(
name: String, app: &'static App,
path: PathBuf, name: Option<String>,
} projects: &[DirEntry],
) -> anyhow::Result<DirEntry> {
impl Searchable for ProjectEntry { match name {
fn display_label(&self) -> String { Some(name) => projects
self.name.clone() .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<Vec<ProjectEntry>> { // --- Command implementations ---
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)
}
impl ProjectCommand { impl ProjectCommand {
pub async fn execute(&mut self, app: &'static App) -> anyhow::Result<()> { 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<()> { async fn open_existing(&self, app: &'static App) -> anyhow::Result<()> {
let projects_dir = get_projects_dir(app); let projects_dir = get_projects_dir(app);
let projects = list_existing_projects(&projects_dir)?; let projects = list_subdirectories(&projects_dir)?;
if projects.is_empty() { if projects.is_empty() {
anyhow::bail!( anyhow::bail!(
@@ -214,7 +282,6 @@ impl ProjectCommand {
.iter() .iter()
.find(|p| p.name.contains(needle.as_str())) .find(|p| p.name.contains(needle.as_str()))
.or_else(|| { .or_else(|| {
// fuzzy fallback
projects.iter().find(|p| { projects.iter().find(|p| {
p.name p.name
.to_lowercase() .to_lowercase()
@@ -246,7 +313,6 @@ impl ProjectCommand {
impl ProjectCreateCommand { impl ProjectCreateCommand {
async fn execute(&mut self, app: &'static App) -> anyhow::Result<()> { async fn execute(&mut self, app: &'static App) -> anyhow::Result<()> {
// Step 1: Get project name
let name = match self.name.take() { let name = match self.name.take() {
Some(n) => n, Some(n) => n,
None => { None => {
@@ -261,7 +327,6 @@ impl ProjectCreateCommand {
} }
}; };
// Sanitize project name for use as directory
let dir_name = name let dir_name = name
.replace(' ', "-") .replace(' ', "-")
.replace('/', "-") .replace('/', "-")
@@ -278,22 +343,8 @@ impl ProjectCreateCommand {
); );
} }
// Step 2: Load repositories let repositories = load_repositories(app, !self.no_cache).await?;
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 (Tab to toggle, Enter to confirm):"); eprintln!("Select repositories (Tab to toggle, Enter to confirm):");
let selected_repos = app let selected_repos = app
.interactive() .interactive()
@@ -303,85 +354,15 @@ impl ProjectCreateCommand {
anyhow::bail!("no repositories selected"); anyhow::bail!("no repositories selected");
} }
// Step 4: Create project directory
tokio::fs::create_dir_all(&project_path).await?; tokio::fs::create_dir_all(&project_path).await?;
// Step 5: Clone each selected repository into the project directory clone_repos_into(app, &selected_repos, &project_path).await?;
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)); // Apply template if requested
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
let templates_dir = get_templates_dir(app); let templates_dir = get_templates_dir(app);
let template = match self.template.take() { let template = match self.template.take() {
Some(name) => { Some(name) => {
let templates = list_templates(&templates_dir)?; let templates = list_subdirectories(&templates_dir)?;
Some( Some(
templates templates
.into_iter() .into_iter()
@@ -396,7 +377,7 @@ impl ProjectCreateCommand {
) )
} }
None => { None => {
let templates = list_templates(&templates_dir)?; let templates = list_subdirectories(&templates_dir)?;
if !templates.is_empty() { if !templates.is_empty() {
eprintln!("Select a project template (Esc to skip):"); eprintln!("Select a project template (Esc to skip):");
app.interactive().interactive_search_items(&templates)? app.interactive().interactive_search_items(&templates)?
@@ -418,7 +399,6 @@ impl ProjectCreateCommand {
selected_repos.len() selected_repos.len()
); );
// Step 6: Enter shell or print path
if !self.no_shell { if !self.no_shell {
app.shell().spawn_shell_at(&project_path).await?; app.shell().spawn_shell_at(&project_path).await?;
} else { } else {
@@ -432,7 +412,7 @@ impl ProjectCreateCommand {
impl ProjectAddCommand { impl ProjectAddCommand {
async fn execute(&mut self, app: &'static App) -> anyhow::Result<()> { async fn execute(&mut self, app: &'static App) -> anyhow::Result<()> {
let projects_dir = get_projects_dir(app); let projects_dir = get_projects_dir(app);
let projects = list_existing_projects(&projects_dir)?; let projects = list_subdirectories(&projects_dir)?;
if projects.is_empty() { if projects.is_empty() {
anyhow::bail!( anyhow::bail!(
@@ -441,35 +421,10 @@ impl ProjectAddCommand {
); );
} }
// Step 1: Select project let project = select_project(app, self.name.take(), &projects)?;
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 = load_repositories(app, !self.no_cache).await?;
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):"); eprintln!("Select repositories to add (Tab to toggle, Enter to confirm):");
let selected_repos = app let selected_repos = app
.interactive() .interactive()
@@ -479,76 +434,7 @@ impl ProjectAddCommand {
anyhow::bail!("no repositories selected"); anyhow::bail!("no repositories selected");
} }
// Step 4: Clone each selected repository into the project directory clone_repos_into(app, &selected_repos, &project.path).await?;
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!( eprintln!(
"added {} repositories to project '{}'", "added {} repositories to project '{}'",
@@ -563,23 +449,13 @@ impl ProjectAddCommand {
impl ProjectDeleteCommand { impl ProjectDeleteCommand {
async fn execute(&mut self, app: &'static App) -> anyhow::Result<()> { async fn execute(&mut self, app: &'static App) -> anyhow::Result<()> {
let projects_dir = get_projects_dir(app); let projects_dir = get_projects_dir(app);
let projects = list_existing_projects(&projects_dir)?; let projects = list_subdirectories(&projects_dir)?;
if projects.is_empty() { if projects.is_empty() {
anyhow::bail!("no projects found in {}", projects_dir.display()); anyhow::bail!("no projects found in {}", projects_dir.display());
} }
let project = match self.name.take() { let project = select_project(app, self.name.take(), &projects)?;
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"))?,
};
if !self.force { if !self.force {
eprint!( eprint!(

View File

@@ -2,7 +2,7 @@ use std::{collections::BTreeMap, io::IsTerminal};
use crate::{ use crate::{
app::App, app::App,
cache::CacheApp, cache::{load_repositories, CacheApp},
components::inline_command::InlineCommand, components::inline_command::InlineCommand,
custom_command::CustomCommandApp, custom_command::CustomCommandApp,
fuzzy_matcher::{FuzzyMatcher, FuzzyMatcherApp}, fuzzy_matcher::{FuzzyMatcher, FuzzyMatcherApp},
@@ -34,29 +34,13 @@ impl RootCommand {
) -> anyhow::Result<()> { ) -> anyhow::Result<()> {
tracing::debug!("executing"); tracing::debug!("executing");
let repositories = if !force_cache_update { 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 {
tracing::info!("forcing cache update..."); tracing::info!("forcing cache update...");
let repositories = self.app.projects_list().get_projects().await?; let repositories = self.app.projects_list().get_projects().await?;
self.app.cache().update(&repositories).await?; self.app.cache().update(&repositories).await?;
repositories repositories
} else {
load_repositories(self.app, cache).await?
}; };
let repo = match search { let repo = match search {
@@ -149,42 +133,21 @@ impl RootCommand {
} }
} }
trait StringExt {
fn as_str_vec(&self) -> Vec<&str>;
}
impl StringExt for Vec<String> {
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 { pub trait RepositoryMatcher {
fn match_repositories(&self, pattern: &str, repositories: &[Repository]) -> Vec<Repository>; fn match_repositories(&self, pattern: &str, repositories: &[Repository]) -> Vec<Repository>;
} }
impl RepositoryMatcher for FuzzyMatcher { impl RepositoryMatcher for FuzzyMatcher {
fn match_repositories(&self, pattern: &str, repositories: &[Repository]) -> Vec<Repository> { fn match_repositories(&self, pattern: &str, repositories: &[Repository]) -> Vec<Repository> {
let haystack = repositories let haystack: BTreeMap<String, &Repository> = repositories
.iter() .iter()
.map(|r| (r.to_rel_path().display().to_string(), r)) .map(|r| (r.to_rel_path().display().to_string(), r))
.collect::<BTreeMap<_, _>>(); .collect();
let haystack_keys = haystack.keys().collect::<Vec<_>>(); let keys: Vec<&str> = haystack.keys().map(|s| s.as_str()).collect();
let haystack_keys = haystack_keys.as_str_vec();
let res = self.match_pattern(pattern, &haystack_keys); self.match_pattern(pattern, &keys)
let matched_repos = res
.into_iter() .into_iter()
.filter_map(|repo_key| haystack.get(repo_key).map(|r| (*r).to_owned())) .filter_map(|key| haystack.get(key).map(|r| (*r).to_owned()))
.collect::<Vec<_>>(); .collect()
matched_repos
} }
} }

View File

@@ -2,11 +2,10 @@ use std::io::IsTerminal;
use crate::{ use crate::{
app::App, app::App,
cache::CacheApp, cache::load_repositories,
components::inline_command::InlineCommand, components::inline_command::InlineCommand,
fuzzy_matcher::FuzzyMatcherApp, fuzzy_matcher::FuzzyMatcherApp,
interactive::{InteractiveApp, StringItem}, interactive::{InteractiveApp, StringItem},
projects_list::ProjectsListApp,
shell::ShellApp, shell::ShellApp,
worktree::{sanitize_branch_name, WorktreeApp}, worktree::{sanitize_branch_name, WorktreeApp},
}; };
@@ -35,19 +34,7 @@ pub struct WorktreeCommand {
impl WorktreeCommand { impl WorktreeCommand {
pub async fn execute(&mut self, app: &'static App) -> anyhow::Result<()> { pub async fn execute(&mut self, app: &'static App) -> anyhow::Result<()> {
// Step 1: Load repositories // Step 1: Load repositories
let repositories = if !self.no_cache { let repositories = load_repositories(app, !self.no_cache).await?;
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?
};
// Step 2: Select repository // Step 2: Select repository
let repo = match &self.search { let repo = match &self.search {

View File

@@ -63,37 +63,26 @@ pub struct WorktreeSettings {
pub list_branches_command: Option<String>, pub list_branches_command: Option<String>,
} }
/// A list of shell commands that can be specified as a single string or an array in TOML.
#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)] #[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
#[serde(untagged)] #[serde(untagged)]
pub enum PostUpdateCommand { pub enum CommandList {
Single(String), Single(String),
Multiple(Vec<String>), Multiple(Vec<String>),
} }
impl PostUpdateCommand { impl CommandList {
pub fn get_commands(&self) -> Vec<String> { pub fn get_commands(&self) -> Vec<String> {
match self.clone() { match self.clone() {
PostUpdateCommand::Single(item) => vec![item], CommandList::Single(item) => vec![item],
PostUpdateCommand::Multiple(items) => items, CommandList::Multiple(items) => items,
} }
} }
} }
#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)] /// Backwards-compatible type aliases.
#[serde(untagged)] pub type PostCloneCommand = CommandList;
pub enum PostCloneCommand { pub type PostUpdateCommand = CommandList;
Single(String),
Multiple(Vec<String>),
}
impl PostCloneCommand {
pub fn get_commands(&self) -> Vec<String> {
match self.clone() {
PostCloneCommand::Single(item) => vec![item],
PostCloneCommand::Multiple(items) => items,
}
}
}
#[derive(Debug, Default, Serialize, Deserialize, PartialEq, Clone)] #[derive(Debug, Default, Serialize, Deserialize, PartialEq, Clone)]
pub struct Projects { pub struct Projects {
@@ -229,35 +218,28 @@ pub struct GitHub {
pub organisations: Vec<GitHubOrganisation>, pub organisations: Vec<GitHubOrganisation>,
} }
#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)] /// Generates a newtype wrapper around `String` with `From` impls for owned and borrowed access.
pub struct GitHubUser(String); macro_rules! string_newtype {
($name:ident) => {
#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
pub struct $name(String);
impl From<GitHubUser> for String { impl From<$name> for String {
fn from(value: GitHubUser) -> Self { fn from(value: $name) -> Self {
value.0 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 { string_newtype!(GitHubUser);
fn from(value: &'a GitHubUser) -> Self { string_newtype!(GitHubOrganisation);
value.0.as_str()
}
}
#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
pub struct GitHubOrganisation(String);
impl From<GitHubOrganisation> 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()
}
}
#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)] #[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
pub struct Gitea { pub struct Gitea {
@@ -289,34 +271,8 @@ pub enum GitHubAccessToken {
Env { env: String }, Env { env: String },
} }
#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)] string_newtype!(GiteaUser);
pub struct GiteaUser(String); string_newtype!(GiteaOrganisation);
impl From<GiteaUser> 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<GiteaOrganisation> 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()
}
}
impl Config { impl Config {
pub async fn from_file(file_path: &Path) -> anyhow::Result<Config> { pub async fn from_file(file_path: &Path) -> anyhow::Result<Config> {

View File

@@ -1,73 +1,65 @@
use std::path::Path; use std::path::Path;
use crate::{app::App, config::Config}; use crate::{app::App, config::CommandList};
pub struct CustomCommand { pub struct CustomCommand {
config: Config, post_clone: Option<CommandList>,
post_update: Option<CommandList>,
} }
impl CustomCommand { impl CustomCommand {
pub fn new(config: Config) -> Self { pub fn new(app: &App) -> Self {
Self { config } 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<()> { async fn execute_commands(
if let Some(post_clone_command) = &self.config.settings.post_clone_command { commands: &CommandList,
for command in post_clone_command.get_commands() { path: &Path,
let command_parts = command.split(' ').collect::<Vec<_>>(); label: &str,
let Some((first, rest)) = command_parts.split_first() else { ) -> anyhow::Result<()> {
return Ok(()); for command in commands.get_commands() {
}; let command_parts = command.split(' ').collect::<Vec<_>>();
let Some((first, rest)) = command_parts.split_first() else {
return Ok(());
};
let mut cmd = tokio::process::Command::new(first); let mut cmd = tokio::process::Command::new(first);
cmd.args(rest).current_dir(path); cmd.args(rest).current_dir(path);
eprintln!("running command: {}", command); eprintln!("running command: {}", command);
tracing::info!( tracing::info!(
path = path.display().to_string(), path = path.display().to_string(),
cmd = command, cmd = command,
"running custom post clone command" "running custom {} command",
); label
let output = cmd.output().await?; );
let stdout = std::str::from_utf8(&output.stdout)?; let output = cmd.output().await?;
tracing::info!( let stdout = std::str::from_utf8(&output.stdout)?;
stdout = stdout, tracing::info!(
"finished running custom post clone command" stdout = stdout,
); "finished running custom {} command",
} label
);
} }
Ok(()) Ok(())
} }
pub async fn execute_post_update_command(&self, path: &Path) -> anyhow::Result<()> { pub async fn execute_post_clone_command(&self, path: &Path) -> anyhow::Result<()> {
if let Some(post_update_command) = &self.config.settings.post_update_command { if let Some(ref cmds) = self.post_clone {
for command in post_update_command.get_commands() { Self::execute_commands(cmds, path, "post clone").await?;
let command_parts = command.split(' ').collect::<Vec<_>>();
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"
);
}
} }
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(()) Ok(())
} }
} }
@@ -78,6 +70,6 @@ pub trait CustomCommandApp {
impl CustomCommandApp for App { impl CustomCommandApp for App {
fn custom_command(&self) -> CustomCommand { fn custom_command(&self) -> CustomCommand {
CustomCommand::new(self.config.clone()) CustomCommand::new(self)
} }
} }