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 {
|
pub trait CacheApp {
|
||||||
fn cache(&self) -> Cache;
|
fn cache(&self) -> Cache;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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!(
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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> {
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user