feat: add project command for scratch-pad multi-repo workspaces
Some checks failed
continuous-integration/drone/push Build encountered an error
Some checks failed
continuous-integration/drone/push Build encountered an error
Adds `gitnow project` with create, delete, and open subcommands. Create allows multi-selecting repositories to clone into a shared project directory. Includes multi-select TUI support in interactive module.
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
pub mod project;
|
||||
pub mod root;
|
||||
pub mod shell;
|
||||
pub mod update;
|
||||
|
||||
358
crates/gitnow/src/commands/project.rs
Normal file
358
crates/gitnow/src/commands/project.rs
Normal file
@@ -0,0 +1,358 @@
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::{
|
||||
app::App,
|
||||
cache::CacheApp,
|
||||
custom_command::CustomCommandApp,
|
||||
interactive::{InteractiveApp, Searchable},
|
||||
projects_list::ProjectsListApp,
|
||||
shell::ShellApp,
|
||||
template_command,
|
||||
};
|
||||
|
||||
#[derive(clap::Parser)]
|
||||
pub struct ProjectCommand {
|
||||
#[command(subcommand)]
|
||||
command: Option<ProjectSubcommand>,
|
||||
|
||||
/// Search string to filter existing projects
|
||||
#[arg()]
|
||||
search: Option<String>,
|
||||
|
||||
/// Skip spawning a shell in the project directory
|
||||
#[arg(long = "no-shell", default_value = "false")]
|
||||
no_shell: bool,
|
||||
}
|
||||
|
||||
#[derive(clap::Subcommand)]
|
||||
enum ProjectSubcommand {
|
||||
/// Create a new project with selected repositories
|
||||
Create(ProjectCreateCommand),
|
||||
/// Delete an existing project
|
||||
Delete(ProjectDeleteCommand),
|
||||
}
|
||||
|
||||
#[derive(clap::Parser)]
|
||||
pub struct ProjectCreateCommand {
|
||||
/// Project name (will be used as directory name)
|
||||
#[arg()]
|
||||
name: Option<String>,
|
||||
|
||||
/// Skip cache when fetching repositories
|
||||
#[arg(long = "no-cache", default_value = "false")]
|
||||
no_cache: bool,
|
||||
|
||||
/// Skip spawning a shell in the project directory
|
||||
#[arg(long = "no-shell", default_value = "false")]
|
||||
no_shell: bool,
|
||||
}
|
||||
|
||||
#[derive(clap::Parser)]
|
||||
pub struct ProjectDeleteCommand {
|
||||
/// Project name to delete
|
||||
#[arg()]
|
||||
name: Option<String>,
|
||||
|
||||
/// Skip confirmation prompt
|
||||
#[arg(long = "force", short = 'f', default_value = "false")]
|
||||
force: bool,
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
return path;
|
||||
}
|
||||
}
|
||||
|
||||
let home = dirs::home_dir().unwrap_or_default();
|
||||
home.join(".gitnow").join("projects")
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct ProjectEntry {
|
||||
name: String,
|
||||
path: PathBuf,
|
||||
}
|
||||
|
||||
impl Searchable for ProjectEntry {
|
||||
fn display_label(&self) -> String {
|
||||
self.name.clone()
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
impl ProjectCommand {
|
||||
pub async fn execute(&mut self, app: &'static App) -> anyhow::Result<()> {
|
||||
match self.command.take() {
|
||||
Some(ProjectSubcommand::Create(mut create)) => create.execute(app).await,
|
||||
Some(ProjectSubcommand::Delete(mut delete)) => delete.execute(app).await,
|
||||
None => self.open_existing(app).await,
|
||||
}
|
||||
}
|
||||
|
||||
async fn open_existing(&self, app: &'static App) -> anyhow::Result<()> {
|
||||
let projects_dir = get_projects_dir(app);
|
||||
let projects = list_existing_projects(&projects_dir)?;
|
||||
|
||||
if projects.is_empty() {
|
||||
anyhow::bail!(
|
||||
"no projects found in {}. Use 'gitnow project create' to create one.",
|
||||
projects_dir.display()
|
||||
);
|
||||
}
|
||||
|
||||
let project = match &self.search {
|
||||
Some(needle) => {
|
||||
let matched = projects
|
||||
.iter()
|
||||
.find(|p| p.name.contains(needle.as_str()))
|
||||
.or_else(|| {
|
||||
// fuzzy fallback
|
||||
projects.iter().find(|p| {
|
||||
p.name
|
||||
.to_lowercase()
|
||||
.contains(&needle.to_lowercase())
|
||||
})
|
||||
})
|
||||
.ok_or(anyhow::anyhow!(
|
||||
"no project matching '{}' found",
|
||||
needle
|
||||
))?
|
||||
.clone();
|
||||
matched
|
||||
}
|
||||
None => app
|
||||
.interactive()
|
||||
.interactive_search_items(&projects)?
|
||||
.ok_or(anyhow::anyhow!("no project selected"))?,
|
||||
};
|
||||
|
||||
if !self.no_shell {
|
||||
app.shell().spawn_shell_at(&project.path).await?;
|
||||
} else {
|
||||
println!("{}", project.path.display());
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
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 => {
|
||||
eprint!("Project name: ");
|
||||
let mut input = String::new();
|
||||
std::io::stdin().read_line(&mut input)?;
|
||||
let trimmed = input.trim().to_string();
|
||||
if trimmed.is_empty() {
|
||||
anyhow::bail!("project name cannot be empty");
|
||||
}
|
||||
trimmed
|
||||
}
|
||||
};
|
||||
|
||||
// Sanitize project name for use as directory
|
||||
let dir_name = name
|
||||
.replace(' ', "-")
|
||||
.replace('/', "-")
|
||||
.to_lowercase();
|
||||
|
||||
let projects_dir = get_projects_dir(app);
|
||||
let project_path = projects_dir.join(&dir_name);
|
||||
|
||||
if project_path.exists() {
|
||||
anyhow::bail!(
|
||||
"project '{}' already exists at {}",
|
||||
dir_name,
|
||||
project_path.display()
|
||||
);
|
||||
}
|
||||
|
||||
// Step 2: Load repositories
|
||||
let repositories = if !self.no_cache {
|
||||
match app.cache().get().await? {
|
||||
Some(repos) => repos,
|
||||
None => {
|
||||
eprintln!("fetching repositories...");
|
||||
let repositories = app.projects_list().get_projects().await?;
|
||||
app.cache().update(&repositories).await?;
|
||||
repositories
|
||||
}
|
||||
}
|
||||
} else {
|
||||
app.projects_list().get_projects().await?
|
||||
};
|
||||
|
||||
// Step 3: Multi-select repositories
|
||||
eprintln!("Select repositories (Tab to toggle, Enter to confirm):");
|
||||
let selected_repos = app
|
||||
.interactive()
|
||||
.interactive_multi_search(&repositories)?;
|
||||
|
||||
if selected_repos.is_empty() {
|
||||
anyhow::bail!("no repositories selected");
|
||||
}
|
||||
|
||||
// Step 4: 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);
|
||||
|
||||
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!(
|
||||
"project '{}' created at {} with {} repositories",
|
||||
dir_name,
|
||||
project_path.display(),
|
||||
selected_repos.len()
|
||||
);
|
||||
|
||||
// Step 6: Enter shell or print path
|
||||
if !self.no_shell {
|
||||
app.shell().spawn_shell_at(&project_path).await?;
|
||||
} else {
|
||||
println!("{}", project_path.display());
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
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)?;
|
||||
|
||||
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"))?,
|
||||
};
|
||||
|
||||
if !self.force {
|
||||
eprint!(
|
||||
"Delete project '{}' at {}? [y/N] ",
|
||||
project.name,
|
||||
project.path.display()
|
||||
);
|
||||
let mut input = String::new();
|
||||
std::io::stdin().read_line(&mut input)?;
|
||||
if !input.trim().eq_ignore_ascii_case("y") {
|
||||
eprintln!("aborted");
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
tokio::fs::remove_dir_all(&project.path).await?;
|
||||
eprintln!("deleted project '{}'", project.name);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,17 @@ pub struct Settings {
|
||||
/// Worktree configuration.
|
||||
#[serde(default)]
|
||||
pub worktree: Option<WorktreeSettings>,
|
||||
|
||||
/// Project scratch-pad configuration.
|
||||
#[serde(default)]
|
||||
pub project: Option<ProjectSettings>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
|
||||
pub struct ProjectSettings {
|
||||
/// Directory where projects are stored.
|
||||
/// Default: "~/.gitnow/projects"
|
||||
pub directory: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
|
||||
@@ -424,6 +435,7 @@ mod test {
|
||||
post_clone_command: None,
|
||||
clone_command: None,
|
||||
worktree: None,
|
||||
project: None,
|
||||
}
|
||||
},
|
||||
config
|
||||
@@ -453,6 +465,7 @@ mod test {
|
||||
post_clone_command: None,
|
||||
clone_command: None,
|
||||
worktree: None,
|
||||
project: None,
|
||||
}
|
||||
},
|
||||
config
|
||||
|
||||
@@ -47,6 +47,16 @@ impl Interactive {
|
||||
|
||||
App::new(self.app, items).run(terminal)
|
||||
}
|
||||
|
||||
pub fn interactive_multi_search<T: Searchable>(
|
||||
&mut self,
|
||||
items: &[T],
|
||||
) -> anyhow::Result<Vec<T>> {
|
||||
let backend = TermwizBackend::new().map_err(|e| anyhow::anyhow!(e.to_string()))?;
|
||||
let terminal = Terminal::new(backend)?;
|
||||
|
||||
multi_select::MultiSelectApp::new(self.app, items).run(terminal)
|
||||
}
|
||||
}
|
||||
|
||||
pub trait InteractiveApp {
|
||||
@@ -197,3 +207,180 @@ mod app {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub mod multi_select {
|
||||
use std::collections::HashSet;
|
||||
|
||||
use crossterm::event::KeyModifiers;
|
||||
use ratatui::{
|
||||
crossterm::event::{self, Event, KeyCode},
|
||||
layout::{Constraint, Layout},
|
||||
prelude::TermwizBackend,
|
||||
style::{Style, Stylize},
|
||||
text::{Line, Span},
|
||||
widgets::{ListItem, ListState, Paragraph, StatefulWidget},
|
||||
Frame, Terminal,
|
||||
};
|
||||
|
||||
use crate::fuzzy_matcher::FuzzyMatcherApp;
|
||||
|
||||
use super::Searchable;
|
||||
|
||||
pub struct MultiSelectApp<'a, T: Searchable> {
|
||||
app: &'static crate::app::App,
|
||||
items: &'a [T],
|
||||
current_search: String,
|
||||
matched_items: Vec<T>,
|
||||
selected_labels: HashSet<String>,
|
||||
list: ListState,
|
||||
}
|
||||
|
||||
impl<'a, T: Searchable> MultiSelectApp<'a, T> {
|
||||
pub fn new(app: &'static crate::app::App, items: &'a [T]) -> Self {
|
||||
Self {
|
||||
app,
|
||||
items,
|
||||
current_search: String::default(),
|
||||
matched_items: Vec::default(),
|
||||
selected_labels: HashSet::new(),
|
||||
list: ListState::default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn update_matched_items(&mut self) {
|
||||
let labels: Vec<String> = self.items.iter().map(|i| i.display_label()).collect();
|
||||
let label_refs: Vec<&str> = labels.iter().map(|s| s.as_str()).collect();
|
||||
|
||||
let matched_keys = self
|
||||
.app
|
||||
.fuzzy_matcher()
|
||||
.match_pattern(&self.current_search, &label_refs);
|
||||
|
||||
self.matched_items = matched_keys
|
||||
.into_iter()
|
||||
.filter_map(|key| {
|
||||
self.items
|
||||
.iter()
|
||||
.find(|i| i.display_label() == key)
|
||||
.cloned()
|
||||
})
|
||||
.collect();
|
||||
|
||||
if self.list.selected().is_none() {
|
||||
self.list.select_first();
|
||||
}
|
||||
}
|
||||
|
||||
fn toggle_current(&mut self) {
|
||||
if let Some(selected) = self.list.selected() {
|
||||
if let Some(item) = self.matched_items.get(selected) {
|
||||
let label = item.display_label();
|
||||
if !self.selected_labels.remove(&label) {
|
||||
self.selected_labels.insert(label);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn run(
|
||||
mut self,
|
||||
mut terminal: Terminal<TermwizBackend>,
|
||||
) -> anyhow::Result<Vec<T>> {
|
||||
self.update_matched_items();
|
||||
|
||||
loop {
|
||||
terminal.draw(|frame| self.draw(frame))?;
|
||||
|
||||
if let Event::Key(key) = event::read()? {
|
||||
if let KeyCode::Char('c') = key.code
|
||||
&& key.modifiers.contains(KeyModifiers::CONTROL)
|
||||
{
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
match key.code {
|
||||
KeyCode::Tab => {
|
||||
self.toggle_current();
|
||||
}
|
||||
KeyCode::Char(letter) => {
|
||||
self.current_search.push(letter);
|
||||
self.update_matched_items();
|
||||
}
|
||||
KeyCode::Backspace => {
|
||||
if !self.current_search.is_empty() {
|
||||
let _ =
|
||||
self.current_search.remove(self.current_search.len() - 1);
|
||||
self.update_matched_items();
|
||||
}
|
||||
}
|
||||
KeyCode::Esc => {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
terminal.resize(ratatui::layout::Rect::ZERO)?;
|
||||
let selected: Vec<T> = self
|
||||
.items
|
||||
.iter()
|
||||
.filter(|i| self.selected_labels.contains(&i.display_label()))
|
||||
.cloned()
|
||||
.collect();
|
||||
return Ok(selected);
|
||||
}
|
||||
KeyCode::Up => self.list.select_next(),
|
||||
KeyCode::Down => self.list.select_previous(),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn draw(&mut self, frame: &mut Frame) {
|
||||
let vertical = Layout::vertical([
|
||||
Constraint::Percentage(100),
|
||||
Constraint::Min(1),
|
||||
Constraint::Min(1),
|
||||
]);
|
||||
let [list_area, input_area, hint_area] = vertical.areas(frame.area());
|
||||
|
||||
let list_items: Vec<ListItem> = self
|
||||
.matched_items
|
||||
.iter()
|
||||
.map(|i| {
|
||||
let label = i.display_label();
|
||||
let marker = if self.selected_labels.contains(&label) {
|
||||
"[x] "
|
||||
} else {
|
||||
"[ ] "
|
||||
};
|
||||
ListItem::from(Line::from(vec![
|
||||
Span::from(marker).green(),
|
||||
Span::from(label),
|
||||
]))
|
||||
})
|
||||
.collect();
|
||||
|
||||
let list = ratatui::widgets::List::new(list_items)
|
||||
.direction(ratatui::widgets::ListDirection::BottomToTop)
|
||||
.scroll_padding(3)
|
||||
.highlight_symbol("> ")
|
||||
.highlight_spacing(ratatui::widgets::HighlightSpacing::Always)
|
||||
.highlight_style(Style::default().bold().white());
|
||||
|
||||
StatefulWidget::render(list, list_area, frame.buffer_mut(), &mut self.list);
|
||||
|
||||
let input = Paragraph::new(Line::from(vec![
|
||||
Span::from("> ").blue(),
|
||||
Span::from(self.current_search.as_str()),
|
||||
Span::from(" ").on_white(),
|
||||
]));
|
||||
frame.render_widget(input, input_area);
|
||||
|
||||
let count = self.selected_labels.len();
|
||||
let hint = Paragraph::new(Line::from(vec![
|
||||
Span::from(format!("{count} selected")).dim(),
|
||||
Span::from(" | Tab: toggle, Enter: confirm").dim(),
|
||||
]));
|
||||
frame.render_widget(hint, hint_area);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ use std::path::PathBuf;
|
||||
use anyhow::Context;
|
||||
use clap::{Parser, Subcommand};
|
||||
use commands::{
|
||||
clone::CloneCommand, root::RootCommand, shell::Shell, update::Update,
|
||||
clone::CloneCommand, project::ProjectCommand, root::RootCommand, shell::Shell, update::Update,
|
||||
worktree::WorktreeCommand,
|
||||
};
|
||||
use config::Config;
|
||||
@@ -61,6 +61,8 @@ enum Commands {
|
||||
Update(Update),
|
||||
Clone(CloneCommand),
|
||||
Worktree(WorktreeCommand),
|
||||
/// Manage scratch-pad projects with multiple repositories
|
||||
Project(ProjectCommand),
|
||||
}
|
||||
|
||||
const DEFAULT_CONFIG_PATH: &str = ".config/gitnow/gitnow.toml";
|
||||
@@ -108,6 +110,9 @@ async fn main() -> anyhow::Result<()> {
|
||||
Commands::Worktree(mut wt) => {
|
||||
wt.execute(app).await?;
|
||||
}
|
||||
Commands::Project(mut project) => {
|
||||
project.execute(app).await?;
|
||||
}
|
||||
},
|
||||
None => {
|
||||
RootCommand::new(app)
|
||||
|
||||
Reference in New Issue
Block a user