diff --git a/Cargo.lock b/Cargo.lock index 056af67..26c9072 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -776,6 +776,7 @@ dependencies = [ "dotenvy", "futures", "gitea-client", + "minijinja", "nucleo-matcher", "octocrab", "pretty_assertions", @@ -784,6 +785,7 @@ dependencies = [ "ratatui", "regex", "serde", + "shell-words", "termwiz 0.23.3", "tokio", "toml", @@ -1345,6 +1347,15 @@ dependencies = [ "unicase", ] +[[package]] +name = "minijinja" +version = "2.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c54f3bcc034dd74496b5ca929fd0b710186672d5ff0b0f255a9ceb259042ece" +dependencies = [ + "serde", +] + [[package]] name = "minimal-lexical" version = "0.2.1" @@ -2289,6 +2300,12 @@ dependencies = [ "lazy_static", ] +[[package]] +name = "shell-words" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" + [[package]] name = "shlex" version = "1.3.0" diff --git a/README.md b/README.md index 5ba8ab4..807948c 100644 --- a/README.md +++ b/README.md @@ -37,4 +37,76 @@ With gitnow 1. `git now` 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`. diff --git a/crates/gitnow/Cargo.toml b/crates/gitnow/Cargo.toml index 2ee872e..622cd5c 100644 --- a/crates/gitnow/Cargo.toml +++ b/crates/gitnow/Cargo.toml @@ -37,6 +37,8 @@ crossterm = { version = "0.28.0", features = ["event-stream"] } futures = "0.3.30" termwiz = "0.23.0" regex = "1.11.1" +minijinja = "2" +shell-words = "1" [dev-dependencies] pretty_assertions = "1.4.0" diff --git a/crates/gitnow/src/commands.rs b/crates/gitnow/src/commands.rs index 8449fe2..9a5de09 100644 --- a/crates/gitnow/src/commands.rs +++ b/crates/gitnow/src/commands.rs @@ -1,6 +1,7 @@ pub mod root; pub mod shell; pub mod update; +pub mod worktree; pub mod clone { use std::sync::Arc; diff --git a/crates/gitnow/src/commands/worktree.rs b/crates/gitnow/src/commands/worktree.rs new file mode 100644 index 0000000..254aaaa --- /dev/null +++ b/crates/gitnow/src/commands/worktree.rs @@ -0,0 +1,166 @@ +use std::io::IsTerminal; + +use crate::{ + app::App, + cache::CacheApp, + components::inline_command::InlineCommand, + fuzzy_matcher::FuzzyMatcherApp, + interactive::{InteractiveApp, StringItem}, + projects_list::ProjectsListApp, + 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, + + /// Branch to check out (skips interactive branch selection) + #[arg(long = "branch", short = 'b')] + branch: Option, + + /// 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 = if !self.no_cache { + match app.cache().get().await? { + Some(repos) => repos, + None => { + tracing::info!("finding repositories..."); + let repositories = app.projects_list().get_projects().await?; + app.cache().update(&repositories).await?; + repositories + } + } + } else { + app.projects_list().get_projects().await? + }; + + // 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 = + 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 // + 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(()) + } +} diff --git a/crates/gitnow/src/config.rs b/crates/gitnow/src/config.rs index 5fd29d0..74d95e2 100644 --- a/crates/gitnow/src/config.rs +++ b/crates/gitnow/src/config.rs @@ -22,6 +22,29 @@ pub struct Settings { pub post_clone_command: Option, pub post_update_command: Option, + + /// Minijinja template for the clone command. + /// Default: "git clone {{ ssh_url }} {{ path }}" + pub clone_command: Option, + + /// Worktree configuration. + #[serde(default)] + pub worktree: Option, +} + +#[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, + + /// Template for adding a worktree. + /// Default: "git -C {{ bare_path }} worktree add {{ worktree_path }} {{ branch }}" + pub add_command: Option, + + /// Template for listing branches. + /// Default: "git -C {{ bare_path }} branch -r --format=%(refname:short)" + pub list_branches_command: Option, } #[derive(Debug, Serialize, Deserialize, PartialEq, Clone)] @@ -398,7 +421,9 @@ mod test { directory: PathBuf::from("git").into() }, post_update_command: None, - post_clone_command: None + post_clone_command: None, + clone_command: None, + worktree: None, } }, config @@ -425,7 +450,9 @@ mod test { cache: Cache::default(), projects: Projects::default(), post_update_command: None, - post_clone_command: None + post_clone_command: None, + clone_command: None, + worktree: None, } }, config @@ -433,4 +460,54 @@ mod test { 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(()) + } } diff --git a/crates/gitnow/src/git_clone.rs b/crates/gitnow/src/git_clone.rs index b218a8a..0e9f539 100644 --- a/crates/gitnow/src/git_clone.rs +++ b/crates/gitnow/src/git_clone.rs @@ -1,4 +1,6 @@ -use crate::{app::App, git_provider::Repository}; +use std::collections::HashMap; + +use crate::{app::App, git_provider::Repository, template_command}; #[derive(Debug, Clone)] pub struct GitClone { @@ -35,20 +37,27 @@ impl GitClone { return Ok(()); } + let template = self + .app + .config + .settings + .clone_command + .as_deref() + .unwrap_or(template_command::DEFAULT_CLONE_COMMAND); + tracing::info!( "cloning: {} into {}", repository.ssh_url.as_str(), &project_path.display().to_string(), ); - let mut cmd = tokio::process::Command::new("git"); - cmd.args([ - "clone", - repository.ssh_url.as_str(), - &project_path.display().to_string(), + let path_str = project_path.display().to_string(); + let context = HashMap::from([ + ("ssh_url", repository.ssh_url.as_str()), + ("path", path_str.as_str()), ]); - let output = cmd.output().await?; + let output = template_command::render_and_execute(template, context).await?; match output.status.success() { true => tracing::debug!( "cloned {} into {}", diff --git a/crates/gitnow/src/interactive.rs b/crates/gitnow/src/interactive.rs index c47b7db..b08e0d6 100644 --- a/crates/gitnow/src/interactive.rs +++ b/crates/gitnow/src/interactive.rs @@ -3,6 +3,25 @@ use ratatui::{prelude::*, Terminal}; 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 { app: &'static crate::app::App, } @@ -16,12 +35,17 @@ impl Interactive { &mut self, repositories: &[Repository], ) -> anyhow::Result> { + self.interactive_search_items(repositories) + } + + pub fn interactive_search_items( + &mut self, + items: &[T], + ) -> anyhow::Result> { let backend = TermwizBackend::new().map_err(|e| anyhow::anyhow!(e.to_string()))?; let terminal = Terminal::new(backend)?; - let app_result = App::new(self.app, repositories).run(terminal); - - app_result + App::new(self.app, items).run(terminal) } } @@ -47,36 +71,47 @@ mod app { Frame, Terminal, }; - use crate::{ - commands::root::RepositoryMatcher, fuzzy_matcher::FuzzyMatcherApp, git_provider::Repository, - }; + use crate::fuzzy_matcher::FuzzyMatcherApp; - pub struct App<'a> { + use super::Searchable; + + pub struct App<'a, T: Searchable> { app: &'static crate::app::App, - repositories: &'a [Repository], + items: &'a [T], current_search: String, - matched_repos: Vec, + matched_items: Vec, list: ListState, } - impl<'a> App<'a> { - pub fn new(app: &'static crate::app::App, repositories: &'a [Repository]) -> Self { + impl<'a, T: Searchable> App<'a, T> { + pub fn new(app: &'static crate::app::App, items: &'a [T]) -> Self { Self { app, - repositories, + items, current_search: String::default(), - matched_repos: Vec::default(), + matched_items: Vec::default(), list: ListState::default(), } } - fn update_matched_repos(&mut self) { - let res = self + fn update_matched_items(&mut self) { + let labels: Vec = 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_repositories(&self.current_search, self.repositories); + .match_pattern(&self.current_search, &label_refs); - self.matched_repos = res; + 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(); @@ -86,39 +121,41 @@ mod app { pub fn run( mut self, mut terminal: Terminal, - ) -> anyhow::Result> { - self.update_matched_repos(); + ) -> anyhow::Result> { + 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 { - if key.modifiers.contains(KeyModifiers::CONTROL) { - return Ok(None); - } + if let KeyCode::Char('c') = key.code + && key.modifiers.contains(KeyModifiers::CONTROL) + { + return Ok(None); } match key.code { KeyCode::Char(letter) => { self.current_search.push(letter); - self.update_matched_repos(); + 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_repos(); + let _ = + self.current_search.remove(self.current_search.len() - 1); + self.update_matched_items(); } } KeyCode::Esc => { return Ok(None); } KeyCode::Enter => { - if let Some(selected) = self.list.selected() { - if let Some(repo) = self.matched_repos.get(selected).cloned() { - terminal.resize(ratatui::layout::Rect::ZERO)?; - return Ok(Some(repo)); - } + if let Some(selected) = self.list.selected() + && let Some(item) = + self.matched_items.get(selected).cloned() + { + terminal.resize(ratatui::layout::Rect::ZERO)?; + return Ok(Some(item)); } return Ok(None); @@ -133,33 +170,22 @@ mod app { fn draw(&mut self, frame: &mut Frame) { let vertical = Layout::vertical([Constraint::Percentage(100), Constraint::Min(1)]); - let [repository_area, input_area] = vertical.areas(frame.area()); + let [list_area, input_area] = vertical.areas(frame.area()); - let repos = &self.matched_repos; + let display_items: Vec = + self.matched_items.iter().map(|i| i.display_label()).collect(); - let repo_items = repos - .iter() - .map(|r| r.to_rel_path().display().to_string()) - .collect::>(); + let list_items: Vec = + display_items.into_iter().map(ListItem::from).collect(); - let repo_list_items = repo_items - .into_iter() - .map(ListItem::from) - .collect::>(); - - let repo_list = ratatui::widgets::List::new(repo_list_items) + 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( - repo_list, - repository_area, - frame.buffer_mut(), - &mut self.list, - ); + StatefulWidget::render(list, list_area, frame.buffer_mut(), &mut self.list); let input = Paragraph::new(Line::from(vec![ Span::from("> ").blue(), diff --git a/crates/gitnow/src/main.rs b/crates/gitnow/src/main.rs index d3f3ede..579d415 100644 --- a/crates/gitnow/src/main.rs +++ b/crates/gitnow/src/main.rs @@ -2,7 +2,10 @@ use std::path::PathBuf; use anyhow::Context; use clap::{Parser, Subcommand}; -use commands::{clone::CloneCommand, root::RootCommand, shell::Shell, update::Update}; +use commands::{ + clone::CloneCommand, root::RootCommand, shell::Shell, update::Update, + worktree::WorktreeCommand, +}; use config::Config; use tracing::level_filters::LevelFilter; use tracing_subscriber::EnvFilter; @@ -20,6 +23,8 @@ mod git_provider; mod interactive; mod projects_list; mod shell; +mod template_command; +mod worktree; #[derive(Parser)] #[command(author, version, about, long_about = Some("Navigate git projects at the speed of thought"))] @@ -27,6 +32,10 @@ struct Command { #[command(subcommand)] command: Option, + /// Path to config file (default: ~/.config/gitnow/gitnow.toml, or $GITNOW_CONFIG) + #[arg(long = "config", short = 'c', global = true)] + config: Option, + #[arg()] search: Option, @@ -51,6 +60,7 @@ enum Commands { Init(Shell), Update(Update), Clone(CloneCommand), + Worktree(WorktreeCommand), } const DEFAULT_CONFIG_PATH: &str = ".config/gitnow/gitnow.toml"; @@ -66,20 +76,24 @@ async fn main() -> anyhow::Result<()> { ) .init(); - 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); - let config_path = std::env::var("GITNOW_CONFIG") - .map(PathBuf::from) - .unwrap_or(default_config_path); + let cli = Command::parse(); + tracing::debug!("Starting cli"); + + let config_path = if let Some(path) = &cli.config { + path.clone() + } else { + 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 app = app::App::new_static(config).await?; - let cli = Command::parse(); - tracing::debug!("Starting cli"); - match cli.command { Some(cmd) => match cmd { Commands::Init(mut shell) => { @@ -91,6 +105,9 @@ async fn main() -> anyhow::Result<()> { Commands::Clone(mut clone) => { clone.execute(app).await?; } + Commands::Worktree(mut wt) => { + wt.execute(app).await?; + } }, None => { RootCommand::new(app) diff --git a/crates/gitnow/src/shell.rs b/crates/gitnow/src/shell.rs index 986d45c..8f0d6a1 100644 --- a/crates/gitnow/src/shell.rs +++ b/crates/gitnow/src/shell.rs @@ -1,3 +1,5 @@ +use std::path::Path; + use anyhow::Context; use crate::{app::App, git_provider::Repository}; @@ -20,10 +22,14 @@ impl Shell { .directory .join(repository.to_rel_path()); - if !project_path.exists() { + self.spawn_shell_at(&project_path).await + } + + pub async fn spawn_shell_at(&self, path: &Path) -> anyhow::Result<()> { + if !path.exists() { anyhow::bail!( "project path: {} does not exists, it is either a file, or hasn't been cloned", - project_path.display() + path.display() ); } @@ -31,7 +37,7 @@ impl Shell { .context("failed to find SHELL variable, required for spawning embedded shells")?; let mut shell_cmd = tokio::process::Command::new(shell); - shell_cmd.current_dir(project_path); + shell_cmd.current_dir(path); let mut process = shell_cmd.spawn().context("failed to spawn child session")?; diff --git a/crates/gitnow/src/template_command.rs b/crates/gitnow/src/template_command.rs new file mode 100644 index 0000000..82a6716 --- /dev/null +++ b/crates/gitnow/src/template_command.rs @@ -0,0 +1,146 @@ +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 { + 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)> { + 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" + ] + ); + } +} diff --git a/crates/gitnow/src/worktree.rs b/crates/gitnow/src/worktree.rs new file mode 100644 index 0000000..522cd09 --- /dev/null +++ b/crates/gitnow/src/worktree.rs @@ -0,0 +1,182 @@ +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: /.bare/ for the bare clone, + /// // 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 `/.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> { + 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 = 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::>() + .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"); + } +} diff --git a/examples/config/git-worktrees.toml b/examples/config/git-worktrees.toml new file mode 100644 index 0000000..e6a53c4 --- /dev/null +++ b/examples/config/git-worktrees.toml @@ -0,0 +1,34 @@ +# 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"] diff --git a/examples/config/jj-worktrees.toml b/examples/config/jj-worktrees.toml new file mode 100644 index 0000000..fae0a5f --- /dev/null +++ b/examples/config/jj-worktrees.toml @@ -0,0 +1,42 @@ +# 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"]