feat: add project command for scratch-pad multi-repo workspaces
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:
2026-03-20 12:53:28 +01:00
parent 38a51f3aa7
commit fe26d71266
5 changed files with 565 additions and 1 deletions

View File

@@ -1,3 +1,4 @@
pub mod project;
pub mod root; pub mod root;
pub mod shell; pub mod shell;
pub mod update; pub mod update;

View 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(())
}
}

View File

@@ -30,6 +30,17 @@ pub struct Settings {
/// Worktree configuration. /// Worktree configuration.
#[serde(default)] #[serde(default)]
pub worktree: Option<WorktreeSettings>, 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)] #[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
@@ -424,6 +435,7 @@ mod test {
post_clone_command: None, post_clone_command: None,
clone_command: None, clone_command: None,
worktree: None, worktree: None,
project: None,
} }
}, },
config config
@@ -453,6 +465,7 @@ mod test {
post_clone_command: None, post_clone_command: None,
clone_command: None, clone_command: None,
worktree: None, worktree: None,
project: None,
} }
}, },
config config

View File

@@ -47,6 +47,16 @@ impl Interactive {
App::new(self.app, items).run(terminal) 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 { 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);
}
}
}

View File

@@ -3,7 +3,7 @@ use std::path::PathBuf;
use anyhow::Context; use anyhow::Context;
use clap::{Parser, Subcommand}; use clap::{Parser, Subcommand};
use commands::{ use commands::{
clone::CloneCommand, root::RootCommand, shell::Shell, update::Update, clone::CloneCommand, project::ProjectCommand, root::RootCommand, shell::Shell, update::Update,
worktree::WorktreeCommand, worktree::WorktreeCommand,
}; };
use config::Config; use config::Config;
@@ -61,6 +61,8 @@ enum Commands {
Update(Update), Update(Update),
Clone(CloneCommand), Clone(CloneCommand),
Worktree(WorktreeCommand), Worktree(WorktreeCommand),
/// Manage scratch-pad projects with multiple repositories
Project(ProjectCommand),
} }
const DEFAULT_CONFIG_PATH: &str = ".config/gitnow/gitnow.toml"; const DEFAULT_CONFIG_PATH: &str = ".config/gitnow/gitnow.toml";
@@ -108,6 +110,9 @@ async fn main() -> anyhow::Result<()> {
Commands::Worktree(mut wt) => { Commands::Worktree(mut wt) => {
wt.execute(app).await?; wt.execute(app).await?;
} }
Commands::Project(mut project) => {
project.execute(app).await?;
}
}, },
None => { None => {
RootCommand::new(app) RootCommand::new(app)