1 Commits

Author SHA1 Message Date
0a49c29771 fix(deps): update all dependencies
Some checks failed
renovate/artifacts Artifact file update failure
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is failing
2025-11-27 01:30:18 +00:00
21 changed files with 884 additions and 2329 deletions

1286
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,5 @@
# Git Now # Git Now
> https://gitnow.kjuulh.io/
Git Now is a utility for easily navigating git projects from common upstream providers. Search, Download, and Enter projects as quickly as you can type. Git Now is a utility for easily navigating git projects from common upstream providers. Search, Download, and Enter projects as quickly as you can type.
![example gif](./assets/gifs/example.gif) ![example gif](./assets/gifs/example.gif)
@@ -37,76 +35,4 @@ With gitnow
1. `git now` 1. `git now`
2. Enter parts of the project name and press enter 2. Enter parts of the project name and press enter
3. Your project is automatically downloaded if it doesn't exist in an opinionated path dir, and move you there. 3. Your project is automatically downloaded if it doesn't exist in an opinionated path dir, and move you there.
## Configuration
Configuration lives at `~/.config/gitnow/gitnow.toml` (override with `$GITNOW_CONFIG`).
### Custom clone command
By default gitnow uses `git clone`. You can override this with any command using a [minijinja](https://docs.rs/minijinja) template:
```toml
[settings]
# Use jj (Jujutsu) instead of git
clone_command = "jj git clone {{ ssh_url }} {{ path }}"
```
Available template variables: `ssh_url`, `path`.
### Worktrees
gitnow supports git worktrees (or jj workspaces) via the `worktree` subcommand. This uses bare repositories so each branch gets its own directory as a sibling:
```
~/git/github.com/owner/repo/
├── .bare/ # bare clone (git clone --bare)
├── main/ # worktree for main branch
├── feature-login/ # worktree for feature/login branch
└── fix-typo/ # worktree for fix/typo branch
```
Usage:
```bash
# Interactive: pick repo, then pick branch
gitnow worktree
# Pre-filter repo
gitnow worktree myproject
# Specify branch directly
gitnow worktree myproject -b feature/login
# Print worktree path instead of entering a shell
gitnow worktree myproject -b main --no-shell
```
All worktree commands are configurable via minijinja templates:
```toml
[settings.worktree]
# Default: "git clone --bare {{ ssh_url }} {{ bare_path }}"
clone_command = "git clone --bare {{ ssh_url }} {{ bare_path }}"
# Default: "git -C {{ bare_path }} worktree add {{ worktree_path }} {{ branch }}"
add_command = "git -C {{ bare_path }} worktree add {{ worktree_path }} {{ branch }}"
# Default: "git -C {{ bare_path }} branch --format=%(refname:short)"
list_branches_command = "git -C {{ bare_path }} branch --format=%(refname:short)"
```
For jj, you might use:
```toml
[settings]
clone_command = "jj git clone {{ ssh_url }} {{ path }}"
[settings.worktree]
clone_command = "jj git clone {{ ssh_url }} {{ bare_path }}"
add_command = "jj -R {{ bare_path }} workspace add --name {{ branch }} {{ worktree_path }}"
list_branches_command = "jj -R {{ bare_path }} bookmark list -T 'name ++ \"\\n\"'"
```
Available template variables for worktree commands: `bare_path`, `worktree_path`, `branch`, `ssh_url`.

View File

@@ -1,7 +1,7 @@
[package] [package]
name = "gitnow" name = "gitnow"
description = "Git Now is a utility for easily navigating git projects from common upstream providers. Search, Download, and Enter projects as quickly as you can type." description = "Git Now is a utility for easily navigating git projects from common upstream providers. Search, Download, and Enter projects as quickly as you can type."
edition = "2024" edition = "2021"
readme = "../../README.md" readme = "../../README.md"
repository = "https://github.com/kjuulh/gitnow" repository = "https://github.com/kjuulh/gitnow"
homepage = "https://gitnow-client.prod.kjuulh.app" homepage = "https://gitnow-client.prod.kjuulh.app"
@@ -22,23 +22,21 @@ dotenvy.workspace = true
serde = { version = "1.0.197", features = ["derive"] } serde = { version = "1.0.197", features = ["derive"] }
uuid = { version = "1.7.0", features = ["v4"] } uuid = { version = "1.7.0", features = ["v4"] }
async-trait = "0.1.82" async-trait = "0.1.82"
toml = "0.8.19" toml = "0.9.0"
gitea-client = { version = "1.22.1" } gitea-client = { version = "1.22.1" }
url = "2.5.2" url = "2.5.2"
octocrab = "0.43.0" octocrab = "0.48.0"
dirs = "6.0.0" dirs = "6.0.0"
prost = "0.13.2" prost = "0.13.2"
prost-types = "0.13.2" prost-types = "0.13.2"
bytes = "1.7.1" bytes = "1.7.1"
nucleo-matcher = "0.3.1" nucleo-matcher = "0.3.1"
ratatui = { version = "0.29.0", features = ["termwiz"] } ratatui = { version = "0.29.0", features = ["termwiz"] }
crossterm = { version = "0.28.0", features = ["event-stream"] } crossterm = { version = "0.29.0", features = ["event-stream"] }
futures = "0.3.30" futures = "0.3.30"
termwiz = "0.23.0" termwiz = "0.23.0"
regex = "1.11.1" regex = "1.11.1"
minijinja = "2"
shell-words = "1"
[dev-dependencies] [dev-dependencies]
pretty_assertions = "1.4.0" pretty_assertions = "1.4.0"

View File

@@ -105,22 +105,6 @@ impl Cache {
} }
} }
/// Load repositories using the cache if available, otherwise fetch and update cache.
pub async fn load_repositories(app: &'static App, use_cache: bool) -> anyhow::Result<Vec<Repository>> {
use crate::projects_list::ProjectsListApp;
if use_cache {
if let Some(repos) = app.cache().get().await? {
return Ok(repos);
}
}
tracing::info!("fetching repositories...");
let repositories = app.projects_list().get_projects().await?;
app.cache().update(&repositories).await?;
Ok(repositories)
}
pub trait CacheApp { pub trait CacheApp {
fn cache(&self) -> Cache; fn cache(&self) -> Cache;
} }

View File

@@ -1,8 +1,6 @@
pub mod project;
pub mod root; pub mod root;
pub mod shell; pub mod shell;
pub mod update; pub mod update;
pub mod worktree;
pub mod clone { pub mod clone {
use std::sync::Arc; use std::sync::Arc;

View File

@@ -1,479 +0,0 @@
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use crate::{
app::App,
cache::load_repositories,
custom_command::CustomCommandApp,
interactive::{InteractiveApp, Searchable},
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),
/// Add repositories to an existing project
Add(ProjectAddCommand),
/// Delete an existing project
Delete(ProjectDeleteCommand),
}
#[derive(clap::Parser)]
pub struct ProjectCreateCommand {
/// Project name (will be used as directory name)
#[arg()]
name: Option<String>,
/// Bootstrap from a template in the templates directory
#[arg(long = "template", short = 't')]
template: 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 ProjectAddCommand {
/// Project name to add repositories to
#[arg()]
name: Option<String>,
/// Skip cache when fetching repositories
#[arg(long = "no-cache", default_value = "false")]
no_cache: 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,
}
// --- Shared helpers ---
/// A named directory entry usable in interactive search.
#[derive(Clone)]
struct DirEntry {
name: String,
path: PathBuf,
}
impl Searchable for DirEntry {
fn display_label(&self) -> String {
self.name.clone()
}
}
/// 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 entries = Vec::new();
for entry in std::fs::read_dir(dir)? {
let entry = entry?;
if entry.file_type()?.is_dir() {
entries.push(DirEntry {
name: entry.file_name().to_string_lossy().to_string(),
path: entry.path(),
});
}
}
entries.sort_by(|a, b| a.name.cmp(&b.name));
Ok(entries)
}
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?;
let dest_path = dst.join(entry.file_name());
if entry.file_type()?.is_dir() {
copy_dir_recursive(&entry.path(), &dest_path)?;
} else {
std::fs::copy(entry.path(), &dest_path)?;
}
}
Ok(())
}
/// 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);
}
}
}
Ok(())
}
/// 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")),
}
}
// --- Command implementations ---
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::Add(mut add)) => add.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_subdirectories(&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(|| {
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<()> {
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
}
};
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()
);
}
let repositories = load_repositories(app, !self.no_cache).await?;
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");
}
tokio::fs::create_dir_all(&project_path).await?;
clone_repos_into(app, &selected_repos, &project_path).await?;
// Apply template if requested
let templates_dir = get_templates_dir(app);
let template = match self.template.take() {
Some(name) => {
let templates = list_subdirectories(&templates_dir)?;
Some(
templates
.into_iter()
.find(|t| t.name == name)
.ok_or_else(|| {
anyhow::anyhow!(
"template '{}' not found in {}",
name,
templates_dir.display()
)
})?,
)
}
None => {
let templates = list_subdirectories(&templates_dir)?;
if !templates.is_empty() {
eprintln!("Select a project template (Esc to skip):");
app.interactive().interactive_search_items(&templates)?
} else {
None
}
}
};
if let Some(template) = template {
eprintln!(" applying template '{}'...", template.name);
copy_dir_recursive(&template.path, &project_path)?;
}
eprintln!(
"project '{}' created at {} with {} repositories",
dir_name,
project_path.display(),
selected_repos.len()
);
if !self.no_shell {
app.shell().spawn_shell_at(&project_path).await?;
} else {
println!("{}", project_path.display());
}
Ok(())
}
}
impl ProjectAddCommand {
async fn execute(&mut self, app: &'static App) -> anyhow::Result<()> {
let projects_dir = get_projects_dir(app);
let projects = list_subdirectories(&projects_dir)?;
if projects.is_empty() {
anyhow::bail!(
"no projects found in {}. Use 'gitnow project create' to create one.",
projects_dir.display()
);
}
let project = select_project(app, self.name.take(), &projects)?;
let repositories = load_repositories(app, !self.no_cache).await?;
eprintln!("Select repositories to add (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");
}
clone_repos_into(app, &selected_repos, &project.path).await?;
eprintln!(
"added {} repositories to project '{}'",
selected_repos.len(),
project.name
);
Ok(())
}
}
impl ProjectDeleteCommand {
async fn execute(&mut self, app: &'static App) -> anyhow::Result<()> {
let projects_dir = get_projects_dir(app);
let projects = list_subdirectories(&projects_dir)?;
if projects.is_empty() {
anyhow::bail!("no projects found in {}", projects_dir.display());
}
let project = select_project(app, self.name.take(), &projects)?;
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

@@ -2,7 +2,7 @@ use std::{collections::BTreeMap, io::IsTerminal};
use crate::{ use crate::{
app::App, app::App,
cache::{load_repositories, CacheApp}, cache::CacheApp,
components::inline_command::InlineCommand, components::inline_command::InlineCommand,
custom_command::CustomCommandApp, custom_command::CustomCommandApp,
fuzzy_matcher::{FuzzyMatcher, FuzzyMatcherApp}, fuzzy_matcher::{FuzzyMatcher, FuzzyMatcherApp},
@@ -34,13 +34,29 @@ 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 {
@@ -133,21 +149,42 @@ 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: BTreeMap<String, &Repository> = repositories let haystack = repositories
.iter() .iter()
.map(|r| (r.to_rel_path().display().to_string(), r)) .map(|r| (r.to_rel_path().display().to_string(), r))
.collect(); .collect::<BTreeMap<_, _>>();
let keys: Vec<&str> = haystack.keys().map(|s| s.as_str()).collect(); let haystack_keys = haystack.keys().collect::<Vec<_>>();
let haystack_keys = haystack_keys.as_str_vec();
self.match_pattern(pattern, &keys) let res = self.match_pattern(pattern, &haystack_keys);
let matched_repos = res
.into_iter() .into_iter()
.filter_map(|key| haystack.get(key).map(|r| (*r).to_owned())) .filter_map(|repo_key| haystack.get(repo_key).map(|r| (*r).to_owned()))
.collect() .collect::<Vec<_>>();
matched_repos
} }
} }

View File

@@ -1,153 +0,0 @@
use std::io::IsTerminal;
use crate::{
app::App,
cache::load_repositories,
components::inline_command::InlineCommand,
fuzzy_matcher::FuzzyMatcherApp,
interactive::{InteractiveApp, StringItem},
shell::ShellApp,
worktree::{sanitize_branch_name, WorktreeApp},
};
use super::root::RepositoryMatcher;
#[derive(clap::Parser)]
pub struct WorktreeCommand {
/// Optional search string to pre-filter repositories
#[arg()]
search: Option<String>,
/// Branch to check out (skips interactive branch selection)
#[arg(long = "branch", short = 'b')]
branch: Option<String>,
/// Skip cache
#[arg(long = "no-cache", default_value = "false")]
no_cache: bool,
/// Skip spawning a shell in the worktree
#[arg(long = "no-shell", default_value = "false")]
no_shell: bool,
}
impl WorktreeCommand {
pub async fn execute(&mut self, app: &'static App) -> anyhow::Result<()> {
// Step 1: Load repositories
let repositories = load_repositories(app, !self.no_cache).await?;
// Step 2: Select repository
let repo = match &self.search {
Some(needle) => {
let matched_repos = app
.fuzzy_matcher()
.match_repositories(needle, &repositories);
matched_repos
.first()
.ok_or(anyhow::anyhow!("failed to find repository"))?
.to_owned()
}
None => app
.interactive()
.interactive_search(&repositories)?
.ok_or(anyhow::anyhow!("failed to find a repository"))?,
};
tracing::debug!("selected repo: {}", repo.to_rel_path().display());
let wt = app.worktree();
let (_project_path, bare_path) = wt.paths(&repo);
// Step 3: Ensure bare clone exists
if !bare_path.exists() {
if std::io::stdout().is_terminal() && !self.no_shell {
let mut wrap_cmd =
InlineCommand::new(format!("cloning: {}", repo.to_rel_path().display()));
let wt = app.worktree();
let repo_clone = repo.clone();
let bare_path_clone = bare_path.clone();
wrap_cmd
.execute(move || async move {
wt.ensure_bare_clone(&repo_clone, &bare_path_clone).await?;
Ok(())
})
.await?;
} else {
eprintln!("bare-cloning repository...");
wt.ensure_bare_clone(&repo, &bare_path).await?;
}
}
// Step 4: List branches
let branches = app.worktree().list_branches(&bare_path).await?;
if branches.is_empty() {
anyhow::bail!("no branches found for {}", repo.to_rel_path().display());
}
// Step 5: Select branch
let branch = match &self.branch {
Some(b) => {
if !branches.contains(b) {
anyhow::bail!(
"branch '{}' not found. Available branches: {}",
b,
branches.join(", ")
);
}
b.clone()
}
None => {
let items: Vec<StringItem> =
branches.into_iter().map(StringItem).collect();
let selected = app
.interactive()
.interactive_search_items(&items)?
.ok_or(anyhow::anyhow!("no branch selected"))?;
selected.0
}
};
// Step 6: Create worktree at <project_path>/<sanitized_branch>/
let sanitized = sanitize_branch_name(&branch);
let (project_path, _) = app.worktree().paths(&repo);
let worktree_path = project_path.join(&sanitized);
if !worktree_path.exists() {
if std::io::stdout().is_terminal() && !self.no_shell {
let mut wrap_cmd =
InlineCommand::new(format!("creating worktree: {}", &branch));
let wt = app.worktree();
let bare_path = bare_path.clone();
let worktree_path = worktree_path.clone();
let branch = branch.clone();
wrap_cmd
.execute(move || async move {
wt.add_worktree(&bare_path, &worktree_path, &branch)
.await?;
Ok(())
})
.await?;
} else {
eprintln!("creating worktree for branch '{}'...", &branch);
app.worktree()
.add_worktree(&bare_path, &worktree_path, &branch)
.await?;
}
} else {
tracing::info!("worktree already exists at {}", worktree_path.display());
}
// Step 7: Enter shell or print path
if !self.no_shell {
app.shell().spawn_shell_at(&worktree_path).await?;
} else {
println!("{}", worktree_path.display());
}
Ok(())
}
}

View File

@@ -3,16 +3,18 @@ use std::time::Duration;
use crossterm::event::{EventStream, KeyCode}; use crossterm::event::{EventStream, KeyCode};
use futures::{FutureExt, StreamExt}; use futures::{FutureExt, StreamExt};
use ratatui::{ use ratatui::{
TerminalOptions, Viewport, crossterm, crossterm,
prelude::*, prelude::*,
widgets::{Block, Padding}, widgets::{Block, Padding},
TerminalOptions, Viewport,
}; };
use crate::components::BatchCommand; use crate::components::BatchCommand;
use super::{ use super::{
Dispatch, IntoCommand, Msg, Receiver, create_dispatch, create_dispatch,
spinner::{Spinner, SpinnerState}, spinner::{Spinner, SpinnerState},
Dispatch, IntoCommand, Msg, Receiver,
}; };
pub struct InlineCommand { pub struct InlineCommand {
@@ -118,7 +120,7 @@ impl InlineCommand {
return Ok(true); return Ok(true);
} }
let mut cmd = self.update_state(msg); let mut cmd = self.update_state(&msg);
loop { loop {
let msg = cmd.into_command().execute(dispatch); let msg = cmd.into_command().execute(dispatch);
@@ -126,7 +128,7 @@ impl InlineCommand {
match msg { match msg {
Some(Msg::Quit) => return Ok(true), Some(Msg::Quit) => return Ok(true),
Some(msg) => { Some(msg) => {
cmd = self.update_state(msg); cmd = self.update_state(&msg);
} }
None => break, None => break,
} }
@@ -161,7 +163,7 @@ impl InlineCommand {
None None
} }
fn update_state(&mut self, msg: Msg) -> impl IntoCommand { fn update_state(&mut self, msg: &Msg) -> impl IntoCommand {
tracing::debug!("handling message: {:?}", msg); tracing::debug!("handling message: {:?}", msg);
let mut batch = BatchCommand::default(); let mut batch = BatchCommand::default();
@@ -176,7 +178,7 @@ impl InlineCommand {
} }
} }
batch.with(self.spinner.update(&msg)); batch.with(self.spinner.update(msg));
batch.into_command() batch.into_command()
} }

View File

@@ -22,67 +22,39 @@ pub struct Settings {
pub post_clone_command: Option<PostCloneCommand>, pub post_clone_command: Option<PostCloneCommand>,
pub post_update_command: Option<PostUpdateCommand>, pub post_update_command: Option<PostUpdateCommand>,
/// Minijinja template for the clone command.
/// Default: "git clone {{ ssh_url }} {{ path }}"
pub clone_command: Option<String>,
/// 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>,
/// Directory containing project templates.
/// Each subdirectory is a template whose files are copied into new projects.
/// Default: "~/.gitnow/templates"
pub templates_directory: Option<String>,
}
#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
pub struct WorktreeSettings {
/// Template for bare-cloning a repository.
/// Default: "git clone --bare {{ ssh_url }} {{ bare_path }}"
pub clone_command: Option<String>,
/// Template for adding a worktree.
/// Default: "git -C {{ bare_path }} worktree add {{ worktree_path }} {{ branch }}"
pub add_command: Option<String>,
/// Template for listing branches.
/// Default: "git -C {{ bare_path }} branch -r --format=%(refname:short)"
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 CommandList { pub enum PostUpdateCommand {
Single(String), Single(String),
Multiple(Vec<String>), Multiple(Vec<String>),
} }
impl CommandList { impl PostUpdateCommand {
pub fn get_commands(&self) -> Vec<String> { pub fn get_commands(&self) -> Vec<String> {
match self.clone() { match self.clone() {
CommandList::Single(item) => vec![item], PostUpdateCommand::Single(item) => vec![item],
CommandList::Multiple(items) => items, PostUpdateCommand::Multiple(items) => items,
} }
} }
} }
/// Backwards-compatible type aliases. #[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
pub type PostCloneCommand = CommandList; #[serde(untagged)]
pub type PostUpdateCommand = CommandList; 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,
}
}
}
#[derive(Debug, Default, Serialize, Deserialize, PartialEq, Clone)] #[derive(Debug, Default, Serialize, Deserialize, PartialEq, Clone)]
pub struct Projects { pub struct Projects {
@@ -176,7 +148,7 @@ impl CacheDuration {
hours, hours,
minutes, minutes,
} => Some( } => Some(
std::time::Duration::from_hours(*days * 24) std::time::Duration::from_days(*days)
+ std::time::Duration::from_hours(*hours) + std::time::Duration::from_hours(*hours)
+ std::time::Duration::from_mins(*minutes), + std::time::Duration::from_mins(*minutes),
), ),
@@ -218,28 +190,35 @@ pub struct GitHub {
pub organisations: Vec<GitHubOrganisation>, pub organisations: Vec<GitHubOrganisation>,
} }
/// Generates a newtype wrapper around `String` with `From` impls for owned and borrowed access. #[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
macro_rules! string_newtype { pub struct GitHubUser(String);
($name:ident) => {
#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
pub struct $name(String);
impl From<$name> for String { impl From<GitHubUser> for String {
fn from(value: $name) -> Self { fn from(value: GitHubUser) -> Self {
value.0 value.0
} }
}
impl<'a> From<&'a $name> for &'a str {
fn from(value: &'a $name) -> Self {
value.0.as_str()
}
}
};
} }
string_newtype!(GitHubUser); impl<'a> From<&'a GitHubUser> for &'a str {
string_newtype!(GitHubOrganisation); 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()
}
}
#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)] #[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
pub struct Gitea { pub struct Gitea {
@@ -271,8 +250,34 @@ pub enum GitHubAccessToken {
Env { env: String }, Env { env: String },
} }
string_newtype!(GiteaUser); #[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
string_newtype!(GiteaOrganisation); 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()
}
}
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> {
@@ -393,10 +398,7 @@ mod test {
directory: PathBuf::from("git").into() directory: PathBuf::from("git").into()
}, },
post_update_command: None, post_update_command: None,
post_clone_command: None, post_clone_command: None
clone_command: None,
worktree: None,
project: None,
} }
}, },
config config
@@ -423,10 +425,7 @@ mod test {
cache: Cache::default(), cache: Cache::default(),
projects: Projects::default(), projects: Projects::default(),
post_update_command: None, post_update_command: None,
post_clone_command: None, post_clone_command: None
clone_command: None,
worktree: None,
project: None,
} }
}, },
config config
@@ -434,54 +433,4 @@ mod test {
Ok(()) Ok(())
} }
#[test]
fn test_can_parse_config_with_clone_command() -> anyhow::Result<()> {
let content = r#"
[settings]
projects = { directory = "git" }
clone_command = "jj git clone {{ ssh_url }} {{ path }}"
"#;
let config = Config::from_string(content)?;
assert_eq!(
config.settings.clone_command,
Some("jj git clone {{ ssh_url }} {{ path }}".to_string())
);
Ok(())
}
#[test]
fn test_can_parse_config_with_worktree() -> anyhow::Result<()> {
let content = r#"
[settings]
projects = { directory = "git" }
[settings.worktree]
clone_command = "jj git clone {{ ssh_url }} {{ bare_path }}"
add_command = "jj workspace add --name {{ branch }} {{ worktree_path }}"
list_branches_command = "jj -R {{ bare_path }} branch list"
"#;
let config = Config::from_string(content)?;
assert_eq!(
config.settings.worktree,
Some(WorktreeSettings {
clone_command: Some(
"jj git clone {{ ssh_url }} {{ bare_path }}".to_string()
),
add_command: Some(
"jj workspace add --name {{ branch }} {{ worktree_path }}".to_string()
),
list_branches_command: Some(
"jj -R {{ bare_path }} branch list".to_string()
),
})
);
Ok(())
}
} }

View File

@@ -1,65 +1,73 @@
use std::path::Path; use std::path::Path;
use crate::{app::App, config::CommandList}; use crate::{app::App, config::Config};
pub struct CustomCommand { pub struct CustomCommand {
post_clone: Option<CommandList>, config: Config,
post_update: Option<CommandList>,
} }
impl CustomCommand { impl CustomCommand {
pub fn new(app: &App) -> Self { pub fn new(config: Config) -> Self {
Self { Self { config }
post_clone: app.config.settings.post_clone_command.clone(),
post_update: app.config.settings.post_update_command.clone(),
}
}
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);
eprintln!("running command: {}", 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_clone_command(&self, path: &Path) -> anyhow::Result<()> { pub async fn execute_post_clone_command(&self, path: &Path) -> anyhow::Result<()> {
if let Some(ref cmds) = self.post_clone { if let Some(post_clone_command) = &self.config.settings.post_clone_command {
Self::execute_commands(cmds, path, "post clone").await?; 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(());
};
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 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"
);
}
} }
Ok(()) Ok(())
} }
pub async fn execute_post_update_command(&self, path: &Path) -> anyhow::Result<()> { pub async fn execute_post_update_command(&self, path: &Path) -> anyhow::Result<()> {
if let Some(ref cmds) = self.post_update { if let Some(post_update_command) = &self.config.settings.post_update_command {
Self::execute_commands(cmds, path, "post update").await?; 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"
);
}
} }
Ok(()) Ok(())
} }
} }
@@ -70,6 +78,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) CustomCommand::new(self.config.clone())
} }
} }

View File

@@ -1,6 +1,4 @@
use std::collections::HashMap; use crate::{app::App, git_provider::Repository};
use crate::{app::App, git_provider::Repository, template_command};
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct GitClone { pub struct GitClone {
@@ -37,27 +35,20 @@ impl GitClone {
return Ok(()); return Ok(());
} }
let template = self
.app
.config
.settings
.clone_command
.as_deref()
.unwrap_or(template_command::DEFAULT_CLONE_COMMAND);
tracing::info!( tracing::info!(
"cloning: {} into {}", "cloning: {} into {}",
repository.ssh_url.as_str(), repository.ssh_url.as_str(),
&project_path.display().to_string(), &project_path.display().to_string(),
); );
let path_str = project_path.display().to_string(); let mut cmd = tokio::process::Command::new("git");
let context = HashMap::from([ cmd.args([
("ssh_url", repository.ssh_url.as_str()), "clone",
("path", path_str.as_str()), repository.ssh_url.as_str(),
&project_path.display().to_string(),
]); ]);
let output = template_command::render_and_execute(template, context).await?; let output = cmd.output().await?;
match output.status.success() { match output.status.success() {
true => tracing::debug!( true => tracing::debug!(
"cloned {} into {}", "cloned {} into {}",

View File

@@ -3,25 +3,6 @@ use ratatui::{prelude::*, Terminal};
use crate::git_provider::Repository; use crate::git_provider::Repository;
pub trait Searchable: Clone {
fn display_label(&self) -> String;
}
impl Searchable for Repository {
fn display_label(&self) -> String {
self.to_rel_path().display().to_string()
}
}
#[derive(Clone)]
pub struct StringItem(pub String);
impl Searchable for StringItem {
fn display_label(&self) -> String {
self.0.clone()
}
}
pub struct Interactive { pub struct Interactive {
app: &'static crate::app::App, app: &'static crate::app::App,
} }
@@ -35,27 +16,12 @@ impl Interactive {
&mut self, &mut self,
repositories: &[Repository], repositories: &[Repository],
) -> anyhow::Result<Option<Repository>> { ) -> anyhow::Result<Option<Repository>> {
self.interactive_search_items(repositories)
}
pub fn interactive_search_items<T: Searchable>(
&mut self,
items: &[T],
) -> anyhow::Result<Option<T>> {
let backend = TermwizBackend::new().map_err(|e| anyhow::anyhow!(e.to_string()))?; let backend = TermwizBackend::new().map_err(|e| anyhow::anyhow!(e.to_string()))?;
let terminal = Terminal::new(backend)?; let terminal = Terminal::new(backend)?;
App::new(self.app, items).run(terminal) let app_result = App::new(self.app, repositories).run(terminal);
}
pub fn interactive_multi_search<T: Searchable>( app_result
&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)
} }
} }
@@ -81,47 +47,36 @@ mod app {
Frame, Terminal, Frame, Terminal,
}; };
use crate::fuzzy_matcher::FuzzyMatcherApp; use crate::{
commands::root::RepositoryMatcher, fuzzy_matcher::FuzzyMatcherApp, git_provider::Repository,
};
use super::Searchable; pub struct App<'a> {
pub struct App<'a, T: Searchable> {
app: &'static crate::app::App, app: &'static crate::app::App,
items: &'a [T], repositories: &'a [Repository],
current_search: String, current_search: String,
matched_items: Vec<T>, matched_repos: Vec<Repository>,
list: ListState, list: ListState,
} }
impl<'a, T: Searchable> App<'a, T> { impl<'a> App<'a> {
pub fn new(app: &'static crate::app::App, items: &'a [T]) -> Self { pub fn new(app: &'static crate::app::App, repositories: &'a [Repository]) -> Self {
Self { Self {
app, app,
items, repositories,
current_search: String::default(), current_search: String::default(),
matched_items: Vec::default(), matched_repos: Vec::default(),
list: ListState::default(), list: ListState::default(),
} }
} }
fn update_matched_items(&mut self) { fn update_matched_repos(&mut self) {
let labels: Vec<String> = self.items.iter().map(|i| i.display_label()).collect(); let res = self
let label_refs: Vec<&str> = labels.iter().map(|s| s.as_str()).collect();
let matched_keys = self
.app .app
.fuzzy_matcher() .fuzzy_matcher()
.match_pattern(&self.current_search, &label_refs); .match_repositories(&self.current_search, self.repositories);
self.matched_items = matched_keys self.matched_repos = res;
.into_iter()
.filter_map(|key| {
self.items
.iter()
.find(|i| i.display_label() == key)
.cloned()
})
.collect();
if self.list.selected().is_none() { if self.list.selected().is_none() {
self.list.select_first(); self.list.select_first();
@@ -131,41 +86,39 @@ mod app {
pub fn run( pub fn run(
mut self, mut self,
mut terminal: Terminal<TermwizBackend>, mut terminal: Terminal<TermwizBackend>,
) -> anyhow::Result<Option<T>> { ) -> anyhow::Result<Option<Repository>> {
self.update_matched_items(); self.update_matched_repos();
loop { loop {
terminal.draw(|frame| self.draw(frame))?; terminal.draw(|frame| self.draw(frame))?;
if let Event::Key(key) = event::read()? { if let Event::Key(key) = event::read()? {
if let KeyCode::Char('c') = key.code if let KeyCode::Char('c') = key.code {
&& key.modifiers.contains(KeyModifiers::CONTROL) if key.modifiers.contains(KeyModifiers::CONTROL) {
{ return Ok(None);
return Ok(None); }
} }
match key.code { match key.code {
KeyCode::Char(letter) => { KeyCode::Char(letter) => {
self.current_search.push(letter); self.current_search.push(letter);
self.update_matched_items(); self.update_matched_repos();
} }
KeyCode::Backspace => { KeyCode::Backspace => {
if !self.current_search.is_empty() { if !self.current_search.is_empty() {
let _ = let _ = self.current_search.remove(self.current_search.len() - 1);
self.current_search.remove(self.current_search.len() - 1); self.update_matched_repos();
self.update_matched_items();
} }
} }
KeyCode::Esc => { KeyCode::Esc => {
return Ok(None); return Ok(None);
} }
KeyCode::Enter => { KeyCode::Enter => {
if let Some(selected) = self.list.selected() if let Some(selected) = self.list.selected() {
&& let Some(item) = if let Some(repo) = self.matched_repos.get(selected).cloned() {
self.matched_items.get(selected).cloned() terminal.resize(ratatui::layout::Rect::ZERO)?;
{ return Ok(Some(repo));
terminal.resize(ratatui::layout::Rect::ZERO)?; }
return Ok(Some(item));
} }
return Ok(None); return Ok(None);
@@ -180,207 +133,41 @@ mod app {
fn draw(&mut self, frame: &mut Frame) { fn draw(&mut self, frame: &mut Frame) {
let vertical = Layout::vertical([Constraint::Percentage(100), Constraint::Min(1)]); let vertical = Layout::vertical([Constraint::Percentage(100), Constraint::Min(1)]);
let [list_area, input_area] = vertical.areas(frame.area()); let [repository_area, input_area] = vertical.areas(frame.area());
let display_items: Vec<String> = let repos = &self.matched_repos;
self.matched_items.iter().map(|i| i.display_label()).collect();
let list_items: Vec<ListItem> = let repo_items = repos
display_items.into_iter().map(ListItem::from).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);
}
}
}
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() .iter()
.map(|i| { .map(|r| r.to_rel_path().display().to_string())
let label = i.display_label(); .collect::<Vec<_>>();
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) let repo_list_items = repo_items
.into_iter()
.map(ListItem::from)
.collect::<Vec<_>>();
let repo_list = ratatui::widgets::List::new(repo_list_items)
.direction(ratatui::widgets::ListDirection::BottomToTop) .direction(ratatui::widgets::ListDirection::BottomToTop)
.scroll_padding(3) .scroll_padding(3)
.highlight_symbol("> ") .highlight_symbol("> ")
.highlight_spacing(ratatui::widgets::HighlightSpacing::Always) .highlight_spacing(ratatui::widgets::HighlightSpacing::Always)
.highlight_style(Style::default().bold().white()); .highlight_style(Style::default().bold().white());
StatefulWidget::render(list, list_area, frame.buffer_mut(), &mut self.list); StatefulWidget::render(
repo_list,
repository_area,
frame.buffer_mut(),
&mut self.list,
);
let input = Paragraph::new(Line::from(vec![ let input = Paragraph::new(Line::from(vec![
Span::from("> ").blue(), Span::from("> ").blue(),
Span::from(self.current_search.as_str()), Span::from(self.current_search.as_str()),
Span::from(" ").on_white(), Span::from(" ").on_white(),
])); ]));
frame.render_widget(input, input_area);
let count = self.selected_labels.len(); frame.render_widget(input, input_area);
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

@@ -1,11 +1,10 @@
#![feature(duration_constructors)]
use std::path::PathBuf; 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,
};
use config::Config; use config::Config;
use tracing::level_filters::LevelFilter; use tracing::level_filters::LevelFilter;
use tracing_subscriber::EnvFilter; use tracing_subscriber::EnvFilter;
@@ -23,8 +22,6 @@ mod git_provider;
mod interactive; mod interactive;
mod projects_list; mod projects_list;
mod shell; mod shell;
mod template_command;
mod worktree;
#[derive(Parser)] #[derive(Parser)]
#[command(author, version, about, long_about = Some("Navigate git projects at the speed of thought"))] #[command(author, version, about, long_about = Some("Navigate git projects at the speed of thought"))]
@@ -32,10 +29,6 @@ struct Command {
#[command(subcommand)] #[command(subcommand)]
command: Option<Commands>, command: Option<Commands>,
/// Path to config file (default: ~/.config/gitnow/gitnow.toml, or $GITNOW_CONFIG)
#[arg(long = "config", short = 'c', global = true)]
config: Option<PathBuf>,
#[arg()] #[arg()]
search: Option<String>, search: Option<String>,
@@ -60,9 +53,6 @@ enum Commands {
Init(Shell), Init(Shell),
Update(Update), Update(Update),
Clone(CloneCommand), Clone(CloneCommand),
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";
@@ -78,24 +68,20 @@ async fn main() -> anyhow::Result<()> {
) )
.init(); .init();
let cli = Command::parse(); let home =
tracing::debug!("Starting cli"); std::env::var("HOME").context("HOME was not found, are you using a proper shell?")?;
let default_config_path = PathBuf::from(home).join(DEFAULT_CONFIG_PATH);
let config_path = if let Some(path) = &cli.config { let config_path = std::env::var("GITNOW_CONFIG")
path.clone() .map(PathBuf::from)
} else { .unwrap_or(default_config_path);
let home =
std::env::var("HOME").context("HOME was not found, are you using a proper shell?")?;
let default_config_path = PathBuf::from(home).join(DEFAULT_CONFIG_PATH);
std::env::var("GITNOW_CONFIG")
.map(PathBuf::from)
.unwrap_or(default_config_path)
};
let config = Config::from_file(&config_path).await?; let config = Config::from_file(&config_path).await?;
let app = app::App::new_static(config).await?; let app = app::App::new_static(config).await?;
let cli = Command::parse();
tracing::debug!("Starting cli");
match cli.command { match cli.command {
Some(cmd) => match cmd { Some(cmd) => match cmd {
Commands::Init(mut shell) => { Commands::Init(mut shell) => {
@@ -107,12 +93,6 @@ async fn main() -> anyhow::Result<()> {
Commands::Clone(mut clone) => { Commands::Clone(mut clone) => {
clone.execute(app).await?; clone.execute(app).await?;
} }
Commands::Worktree(mut wt) => {
wt.execute(app).await?;
}
Commands::Project(mut project) => {
project.execute(app).await?;
}
}, },
None => { None => {
RootCommand::new(app) RootCommand::new(app)

View File

@@ -1,5 +1,3 @@
use std::path::Path;
use anyhow::Context; use anyhow::Context;
use crate::{app::App, git_provider::Repository}; use crate::{app::App, git_provider::Repository};
@@ -22,14 +20,10 @@ impl Shell {
.directory .directory
.join(repository.to_rel_path()); .join(repository.to_rel_path());
self.spawn_shell_at(&project_path).await if !project_path.exists() {
}
pub async fn spawn_shell_at(&self, path: &Path) -> anyhow::Result<()> {
if !path.exists() {
anyhow::bail!( anyhow::bail!(
"project path: {} does not exists, it is either a file, or hasn't been cloned", "project path: {} does not exists, it is either a file, or hasn't been cloned",
path.display() project_path.display()
); );
} }
@@ -37,7 +31,7 @@ impl Shell {
.context("failed to find SHELL variable, required for spawning embedded shells")?; .context("failed to find SHELL variable, required for spawning embedded shells")?;
let mut shell_cmd = tokio::process::Command::new(shell); let mut shell_cmd = tokio::process::Command::new(shell);
shell_cmd.current_dir(path); shell_cmd.current_dir(project_path);
let mut process = shell_cmd.spawn().context("failed to spawn child session")?; let mut process = shell_cmd.spawn().context("failed to spawn child session")?;

View File

@@ -1,146 +0,0 @@
use std::collections::HashMap;
use anyhow::Context;
pub const DEFAULT_CLONE_COMMAND: &str = "git clone {{ ssh_url }} {{ path }}";
pub const DEFAULT_WORKTREE_CLONE_COMMAND: &str =
"git clone --bare {{ ssh_url }} {{ bare_path }}";
pub const DEFAULT_WORKTREE_ADD_COMMAND: &str =
"git -C {{ bare_path }} worktree add {{ worktree_path }} {{ branch }}";
pub const DEFAULT_LIST_BRANCHES_COMMAND: &str =
"git -C {{ bare_path }} branch --format=%(refname:short)";
pub async fn render_and_execute(
template: &str,
context: HashMap<&str, &str>,
) -> anyhow::Result<std::process::Output> {
let (program, args) = render_command_parts(template, &context)?;
tracing::debug!("executing: {} {}", program, args.join(" "));
let output = tokio::process::Command::new(&program)
.args(&args)
.output()
.await
.with_context(|| format!("failed to execute: {} {}", program, args.join(" ")))?;
Ok(output)
}
fn render_command_parts(
template: &str,
context: &HashMap<&str, &str>,
) -> anyhow::Result<(String, Vec<String>)> {
let env = minijinja::Environment::new();
let rendered = env
.render_str(template, context)
.context("failed to render command template")?;
let parts =
shell_words::split(&rendered).context("failed to parse rendered command as shell words")?;
let (program, args) = parts
.split_first()
.ok_or_else(|| anyhow::anyhow!("command template rendered to empty string"))?;
Ok((program.clone(), args.to_vec()))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_render_clone_command() {
let context = HashMap::from([
("ssh_url", "ssh://git@github.com/owner/repo.git"),
("path", "/home/user/git/github.com/owner/repo"),
]);
let (program, args) = render_command_parts(DEFAULT_CLONE_COMMAND, &context).unwrap();
assert_eq!(program, "git");
assert_eq!(
args,
vec![
"clone",
"ssh://git@github.com/owner/repo.git",
"/home/user/git/github.com/owner/repo"
]
);
}
#[test]
fn test_render_jj_clone_command() {
let template = "jj git clone {{ ssh_url }} {{ path }}";
let context = HashMap::from([
("ssh_url", "ssh://git@github.com/owner/repo.git"),
("path", "/home/user/git/github.com/owner/repo"),
]);
let (program, args) = render_command_parts(template, &context).unwrap();
assert_eq!(program, "jj");
assert_eq!(
args,
vec![
"git",
"clone",
"ssh://git@github.com/owner/repo.git",
"/home/user/git/github.com/owner/repo"
]
);
}
#[test]
fn test_render_worktree_clone_command() {
let context = HashMap::from([
("ssh_url", "ssh://git@github.com/owner/repo.git"),
(
"bare_path",
"/home/user/git/github.com/owner/repo/.bare",
),
]);
let (program, args) =
render_command_parts(DEFAULT_WORKTREE_CLONE_COMMAND, &context).unwrap();
assert_eq!(program, "git");
assert_eq!(
args,
vec![
"clone",
"--bare",
"ssh://git@github.com/owner/repo.git",
"/home/user/git/github.com/owner/repo/.bare"
]
);
}
#[test]
fn test_render_worktree_add_command() {
let context = HashMap::from([
(
"bare_path",
"/home/user/git/github.com/owner/repo/.bare",
),
(
"worktree_path",
"/home/user/git/github.com/owner/repo/feature-x",
),
("branch", "feature/x"),
]);
let (program, args) =
render_command_parts(DEFAULT_WORKTREE_ADD_COMMAND, &context).unwrap();
assert_eq!(program, "git");
assert_eq!(
args,
vec![
"-C",
"/home/user/git/github.com/owner/repo/.bare",
"worktree",
"add",
"/home/user/git/github.com/owner/repo/feature-x",
"feature/x"
]
);
}
}

View File

@@ -1,182 +0,0 @@
use std::collections::BTreeSet;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use crate::{app::App, git_provider::Repository, template_command};
pub struct Worktree {
app: &'static App,
}
impl Worktree {
pub fn new(app: &'static App) -> Self {
Self { app }
}
/// Returns the project path and bare path for a repository in worktree mode.
/// Layout: <project_path>/.bare/ for the bare clone,
/// <project_path>/<branch>/ for each worktree.
pub fn paths(&self, repository: &Repository) -> (PathBuf, PathBuf) {
let project_path = self
.app
.config
.settings
.projects
.directory
.join(repository.to_rel_path());
let bare_path = project_path.join(".bare");
(project_path, bare_path)
}
/// Ensures a bare clone exists at `<project_path>/.bare/`.
/// Skips if already present.
pub async fn ensure_bare_clone(
&self,
repository: &Repository,
bare_path: &Path,
) -> anyhow::Result<()> {
if bare_path.exists() {
tracing::info!("bare clone already exists at {}", bare_path.display());
return Ok(());
}
let template = self
.app
.config
.settings
.worktree
.as_ref()
.and_then(|w| w.clone_command.as_deref())
.unwrap_or(template_command::DEFAULT_WORKTREE_CLONE_COMMAND);
let bare_path_str = bare_path.display().to_string();
let context = HashMap::from([
("ssh_url", repository.ssh_url.as_str()),
("bare_path", bare_path_str.as_str()),
]);
tracing::info!(
"bare-cloning {} into {}",
repository.ssh_url.as_str(),
bare_path.display()
);
let output = template_command::render_and_execute(template, context).await?;
if !output.status.success() {
let stderr = std::str::from_utf8(&output.stderr).unwrap_or_default();
anyhow::bail!("failed to bare-clone: {}", stderr);
}
Ok(())
}
pub async fn list_branches(&self, bare_path: &Path) -> anyhow::Result<Vec<String>> {
let template = self
.app
.config
.settings
.worktree
.as_ref()
.and_then(|w| w.list_branches_command.as_deref())
.unwrap_or(template_command::DEFAULT_LIST_BRANCHES_COMMAND);
let bare_path_str = bare_path.display().to_string();
let context = HashMap::from([("bare_path", bare_path_str.as_str())]);
let output = template_command::render_and_execute(template, context).await?;
if !output.status.success() {
let stderr = std::str::from_utf8(&output.stderr).unwrap_or_default();
anyhow::bail!("failed to list branches: {}", stderr);
}
let stdout = std::str::from_utf8(&output.stdout)?;
let branches: Vec<String> = stdout
.lines()
.map(|l| l.trim())
.filter(|l| !l.is_empty())
.filter(|l| !l.contains("HEAD"))
// Strip origin/ prefix if present (for non-bare repos or custom commands)
.map(|l| l.strip_prefix("origin/").unwrap_or(l).to_string())
.collect::<BTreeSet<_>>()
.into_iter()
.collect();
Ok(branches)
}
pub async fn add_worktree(
&self,
bare_path: &Path,
worktree_path: &Path,
branch: &str,
) -> anyhow::Result<()> {
let template = self
.app
.config
.settings
.worktree
.as_ref()
.and_then(|w| w.add_command.as_deref())
.unwrap_or(template_command::DEFAULT_WORKTREE_ADD_COMMAND);
let bare_path_str = bare_path.display().to_string();
let worktree_path_str = worktree_path.display().to_string();
let context = HashMap::from([
("bare_path", bare_path_str.as_str()),
("worktree_path", worktree_path_str.as_str()),
("branch", branch),
]);
tracing::info!(
"creating worktree for branch '{}' at {}",
branch,
worktree_path.display()
);
let output = template_command::render_and_execute(template, context).await?;
if !output.status.success() {
let stderr = std::str::from_utf8(&output.stderr).unwrap_or_default();
anyhow::bail!("failed to create worktree: {}", stderr);
}
Ok(())
}
}
pub fn sanitize_branch_name(branch: &str) -> String {
let sanitized = branch.replace('/', "-");
if let Some(stripped) = sanitized.strip_prefix('.') {
format!("_{stripped}")
} else {
sanitized
}
}
pub trait WorktreeApp {
fn worktree(&self) -> Worktree;
}
impl WorktreeApp for &'static App {
fn worktree(&self) -> Worktree {
Worktree::new(self)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_sanitize_branch_name() {
assert_eq!(sanitize_branch_name("feature/login"), "feature-login");
assert_eq!(sanitize_branch_name("main"), "main");
assert_eq!(
sanitize_branch_name("fix/nested/path"),
"fix-nested-path"
);
assert_eq!(sanitize_branch_name(".hidden"), "_hidden");
}
}

View File

@@ -1,34 +0,0 @@
# Example: git with worktree support
#
# Uses bare repositories for worktrees. Directory layout:
#
# ~/git/github.com/owner/repo/
# ├── .bare/ # bare clone
# ├── main/ # worktree
# └── feature-x/ # worktree
#
# Usage:
# gitnow worktree # pick repo, then pick branch
# gitnow worktree myrepo -b main
[settings]
# Normal clone (used by `gitnow` without worktree subcommand)
# clone_command = "git clone {{ ssh_url }} {{ path }}"
[settings.worktree]
# All of these are the defaults — shown here for reference.
# You only need [settings.worktree] if you want to override them.
# Bare clone for worktree repos
clone_command = "git clone --bare {{ ssh_url }} {{ bare_path }}"
# Create a worktree from the bare repo
add_command = "git -C {{ bare_path }} worktree add {{ worktree_path }} {{ branch }}"
# List branches in the bare repo
list_branches_command = "git -C {{ bare_path }} branch --format=%(refname:short)"
[[providers.github]]
current_user = "your-user"
access_token = { env = "GITHUB_ACCESS_TOKEN" }
organisations = ["your-org"]

View File

@@ -1,42 +0,0 @@
# Example: jj (Jujutsu) with workspace support
#
# Uses jj for both normal clones and worktrees/workspaces.
#
# Normal clone (`gitnow`):
# ~/git/github.com/owner/repo/ # jj git clone
#
# Worktree/workspace (`gitnow worktree`):
# ~/git/github.com/owner/repo/
# ├── .bare/ # jj git clone (used as the main repo)
# ├── main/ # jj workspace
# └── feature-x/ # jj workspace
#
# Usage:
# gitnow # clone with jj, enter repo
# gitnow worktree # pick repo, then pick branch/workspace
# gitnow worktree myrepo -b main
[settings]
# Use jj for normal clones
clone_command = "jj git clone {{ ssh_url }} {{ path }}"
# Runs after a project is fetched for the first time
post_clone_command = "jj git fetch --all-remotes"
# Runs when jumping to an already-cloned project
post_update_command = "jj git fetch --all-remotes"
[settings.worktree]
# Clone the repo for worktree use
clone_command = "jj git clone {{ ssh_url }} {{ bare_path }}"
# Create a new jj workspace for the selected branch
add_command = "jj -R {{ bare_path }} workspace add --name {{ branch }} {{ worktree_path }}"
# List bookmarks from the jj repo (one name per line)
list_branches_command = "jj -R {{ bare_path }} bookmark list -T 'name ++ \"\\n\"'"
[[providers.github]]
current_user = "your-user"
access_token = { env = "GITHUB_ACCESS_TOKEN" }
organisations = ["your-org"]

View File

@@ -1,6 +0,0 @@
[tools]
rust = "1.93"
cargo = "latest"
[tasks.install]
run = "cargo install --path ./crates/gitnow"

View File

@@ -1,17 +0,0 @@
# Project Spec
## Overview
<!-- Describe the purpose and goals of this project -->
## Repositories
<!-- List the repositories included and their roles -->
## Architecture
<!-- Describe how the repositories relate to each other -->
## Getting Started
<!-- Steps to get up and running after cloning -->