refactor: eliminate code duplication across gitnow codebase
Some checks failed
continuous-integration/drone/push Build encountered an error
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:
@@ -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 {
|
||||
fn cache(&self) -> Cache;
|
||||
}
|
||||
|
||||
@@ -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<Vec<TemplateEntry>> {
|
||||
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<Vec<DirEntry>> {
|
||||
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<String>,
|
||||
projects: &[DirEntry],
|
||||
) -> anyhow::Result<DirEntry> {
|
||||
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<Vec<ProjectEntry>> {
|
||||
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!(
|
||||
|
||||
@@ -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<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 {
|
||||
fn match_repositories(&self, pattern: &str, repositories: &[Repository]) -> Vec<Repository>;
|
||||
}
|
||||
|
||||
impl RepositoryMatcher for FuzzyMatcher {
|
||||
fn match_repositories(&self, pattern: &str, repositories: &[Repository]) -> Vec<Repository> {
|
||||
let haystack = repositories
|
||||
let haystack: BTreeMap<String, &Repository> = repositories
|
||||
.iter()
|
||||
.map(|r| (r.to_rel_path().display().to_string(), r))
|
||||
.collect::<BTreeMap<_, _>>();
|
||||
let haystack_keys = haystack.keys().collect::<Vec<_>>();
|
||||
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::<Vec<_>>();
|
||||
|
||||
matched_repos
|
||||
.filter_map(|key| haystack.get(key).map(|r| (*r).to_owned()))
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -63,37 +63,26 @@ pub struct WorktreeSettings {
|
||||
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)]
|
||||
#[serde(untagged)]
|
||||
pub enum PostUpdateCommand {
|
||||
pub enum CommandList {
|
||||
Single(String),
|
||||
Multiple(Vec<String>),
|
||||
}
|
||||
|
||||
impl PostUpdateCommand {
|
||||
impl CommandList {
|
||||
pub fn get_commands(&self) -> Vec<String> {
|
||||
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<String>),
|
||||
}
|
||||
|
||||
impl PostCloneCommand {
|
||||
pub fn get_commands(&self) -> Vec<String> {
|
||||
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<GitHubOrganisation>,
|
||||
}
|
||||
|
||||
#[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<GitHubUser> 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<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()
|
||||
}
|
||||
}
|
||||
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<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()
|
||||
}
|
||||
}
|
||||
string_newtype!(GiteaUser);
|
||||
string_newtype!(GiteaOrganisation);
|
||||
|
||||
impl Config {
|
||||
pub async fn from_file(file_path: &Path) -> anyhow::Result<Config> {
|
||||
|
||||
@@ -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<CommandList>,
|
||||
post_update: Option<CommandList>,
|
||||
}
|
||||
|
||||
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::<Vec<_>>();
|
||||
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::<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);
|
||||
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::<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"
|
||||
);
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user