1 Commits

Author SHA1 Message Date
a5f42b7651 fix(deps): update all dependencies
Some checks failed
renovate/artifacts Artifact file update failure
continuous-integration/drone/push Build encountered an error
continuous-integration/drone/pr Build encountered an error
2026-02-26 04:32:30 +00:00
14 changed files with 91 additions and 888 deletions

47
Cargo.lock generated
View File

@@ -62,7 +62,7 @@ version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
dependencies = [
"windows-sys 0.61.2",
"windows-sys 0.60.2",
]
[[package]]
@@ -73,14 +73,14 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
dependencies = [
"anstyle",
"once_cell_polyfill",
"windows-sys 0.61.2",
"windows-sys 0.60.2",
]
[[package]]
name = "anyhow"
version = "1.0.100"
version = "1.0.102"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[package]]
name = "arc-swap"
@@ -242,9 +242,9 @@ dependencies = [
[[package]]
name = "clap"
version = "4.5.56"
version = "4.5.60"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a75ca66430e33a14957acc24c5077b503e7d374151b2b4b3a10c83b4ceb4be0e"
checksum = "2797f34da339ce31042b27d23607e051786132987f595b02ba4f6a6dffb7030a"
dependencies = [
"clap_builder",
"clap_derive",
@@ -252,9 +252,9 @@ dependencies = [
[[package]]
name = "clap_builder"
version = "4.5.56"
version = "4.5.60"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "793207c7fa6300a0608d1080b858e5fdbe713cdc1c8db9fb17777d8a13e63df0"
checksum = "24a241312cea5059b13574bb9b3861cabf758b879c15190b37b6d6fd63ab6876"
dependencies = [
"anstream",
"anstyle",
@@ -276,9 +276,9 @@ dependencies = [
[[package]]
name = "clap_lex"
version = "0.7.7"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3e64b0cc0439b12df2fa678eae89a1c56a529fd067a9115f7827f1fffd22b32"
checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831"
[[package]]
name = "colorchoice"
@@ -484,7 +484,7 @@ dependencies = [
"libc",
"option-ext",
"redox_users 0.5.2",
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@@ -532,7 +532,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.61.2",
"windows-sys 0.52.0",
]
[[package]]
@@ -776,7 +776,6 @@ dependencies = [
"dotenvy",
"futures",
"gitea-client",
"minijinja",
"nucleo-matcher",
"octocrab",
"pretty_assertions",
@@ -785,7 +784,6 @@ dependencies = [
"ratatui",
"regex",
"serde",
"shell-words",
"termwiz 0.23.3",
"tokio",
"toml",
@@ -1347,15 +1345,6 @@ 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"
@@ -1433,7 +1422,7 @@ version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@@ -2054,7 +2043,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys 0.11.0",
"windows-sys 0.61.2",
"windows-sys 0.52.0",
]
[[package]]
@@ -2300,12 +2289,6 @@ 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"
@@ -2529,7 +2512,7 @@ dependencies = [
"getrandom 0.3.4",
"once_cell",
"rustix 1.1.3",
"windows-sys 0.61.2",
"windows-sys 0.52.0",
]
[[package]]

View File

@@ -37,76 +37,4 @@ 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.
## 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`.
3. Your project is automatically downloaded if it doesn't exist in an opinionated path dir, and move you there.

View File

@@ -22,23 +22,21 @@ dotenvy.workspace = true
serde = { version = "1.0.197", features = ["derive"] }
uuid = { version = "1.7.0", features = ["v4"] }
async-trait = "0.1.82"
toml = "0.8.19"
toml = "1.0.0"
gitea-client = { version = "1.22.1" }
url = "2.5.2"
octocrab = "0.43.0"
octocrab = "0.49.0"
dirs = "6.0.0"
prost = "0.13.2"
prost-types = "0.13.2"
bytes = "1.7.1"
nucleo-matcher = "0.3.1"
ratatui = { version = "0.29.0", features = ["termwiz"] }
crossterm = { version = "0.28.0", features = ["event-stream"] }
ratatui = { version = "0.30.0", features = ["termwiz"] }
crossterm = { version = "0.29.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"

View File

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

View File

@@ -1,166 +0,0 @@
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<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 = 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<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

@@ -22,29 +22,6 @@ pub struct Settings {
pub post_clone_command: Option<PostCloneCommand>,
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>,
}
#[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>,
}
#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
@@ -421,9 +398,7 @@ mod test {
directory: PathBuf::from("git").into()
},
post_update_command: None,
post_clone_command: None,
clone_command: None,
worktree: None,
post_clone_command: None
}
},
config
@@ -450,9 +425,7 @@ mod test {
cache: Cache::default(),
projects: Projects::default(),
post_update_command: None,
post_clone_command: None,
clone_command: None,
worktree: None,
post_clone_command: None
}
},
config
@@ -460,54 +433,4 @@ 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(())
}
}

View File

@@ -1,6 +1,4 @@
use std::collections::HashMap;
use crate::{app::App, git_provider::Repository, template_command};
use crate::{app::App, git_provider::Repository};
#[derive(Debug, Clone)]
pub struct GitClone {
@@ -37,27 +35,20 @@ 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 path_str = project_path.display().to_string();
let context = HashMap::from([
("ssh_url", repository.ssh_url.as_str()),
("path", path_str.as_str()),
let mut cmd = tokio::process::Command::new("git");
cmd.args([
"clone",
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() {
true => tracing::debug!(
"cloned {} into {}",

View File

@@ -3,25 +3,6 @@ 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,
}
@@ -35,17 +16,12 @@ impl Interactive {
&mut self,
repositories: &[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 terminal = Terminal::new(backend)?;
App::new(self.app, items).run(terminal)
let app_result = App::new(self.app, repositories).run(terminal);
app_result
}
}
@@ -71,47 +47,36 @@ mod app {
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, T: Searchable> {
pub struct App<'a> {
app: &'static crate::app::App,
items: &'a [T],
repositories: &'a [Repository],
current_search: String,
matched_items: Vec<T>,
matched_repos: Vec<Repository>,
list: ListState,
}
impl<'a, T: Searchable> App<'a, T> {
pub fn new(app: &'static crate::app::App, items: &'a [T]) -> Self {
impl<'a> App<'a> {
pub fn new(app: &'static crate::app::App, repositories: &'a [Repository]) -> Self {
Self {
app,
items,
repositories,
current_search: String::default(),
matched_items: Vec::default(),
matched_repos: Vec::default(),
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
fn update_matched_repos(&mut self) {
let res = self
.app
.fuzzy_matcher()
.match_pattern(&self.current_search, &label_refs);
.match_repositories(&self.current_search, self.repositories);
self.matched_items = matched_keys
.into_iter()
.filter_map(|key| {
self.items
.iter()
.find(|i| i.display_label() == key)
.cloned()
})
.collect();
self.matched_repos = res;
if self.list.selected().is_none() {
self.list.select_first();
@@ -121,41 +86,39 @@ mod app {
pub fn run(
mut self,
mut terminal: Terminal<TermwizBackend>,
) -> anyhow::Result<Option<T>> {
self.update_matched_items();
) -> anyhow::Result<Option<Repository>> {
self.update_matched_repos();
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(None);
if let KeyCode::Char('c') = key.code {
if key.modifiers.contains(KeyModifiers::CONTROL) {
return Ok(None);
}
}
match key.code {
KeyCode::Char(letter) => {
self.current_search.push(letter);
self.update_matched_items();
self.update_matched_repos();
}
KeyCode::Backspace => {
if !self.current_search.is_empty() {
let _ =
self.current_search.remove(self.current_search.len() - 1);
self.update_matched_items();
let _ = self.current_search.remove(self.current_search.len() - 1);
self.update_matched_repos();
}
}
KeyCode::Esc => {
return Ok(None);
}
KeyCode::Enter => {
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));
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));
}
}
return Ok(None);
@@ -170,22 +133,33 @@ mod app {
fn draw(&mut self, frame: &mut Frame) {
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> =
self.matched_items.iter().map(|i| i.display_label()).collect();
let repos = &self.matched_repos;
let list_items: Vec<ListItem> =
display_items.into_iter().map(ListItem::from).collect();
let repo_items = repos
.iter()
.map(|r| r.to_rel_path().display().to_string())
.collect::<Vec<_>>();
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)
.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);
StatefulWidget::render(
repo_list,
repository_area,
frame.buffer_mut(),
&mut self.list,
);
let input = Paragraph::new(Line::from(vec![
Span::from("> ").blue(),

View File

@@ -2,10 +2,7 @@ use std::path::PathBuf;
use anyhow::Context;
use clap::{Parser, Subcommand};
use commands::{
clone::CloneCommand, root::RootCommand, shell::Shell, update::Update,
worktree::WorktreeCommand,
};
use commands::{clone::CloneCommand, root::RootCommand, shell::Shell, update::Update};
use config::Config;
use tracing::level_filters::LevelFilter;
use tracing_subscriber::EnvFilter;
@@ -23,8 +20,6 @@ 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"))]
@@ -32,10 +27,6 @@ struct Command {
#[command(subcommand)]
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()]
search: Option<String>,
@@ -60,7 +51,6 @@ enum Commands {
Init(Shell),
Update(Update),
Clone(CloneCommand),
Worktree(WorktreeCommand),
}
const DEFAULT_CONFIG_PATH: &str = ".config/gitnow/gitnow.toml";
@@ -76,24 +66,20 @@ async fn main() -> anyhow::Result<()> {
)
.init();
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 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 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) => {
@@ -105,9 +91,6 @@ 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)

View File

@@ -1,5 +1,3 @@
use std::path::Path;
use anyhow::Context;
use crate::{app::App, git_provider::Repository};
@@ -22,14 +20,10 @@ impl Shell {
.directory
.join(repository.to_rel_path());
self.spawn_shell_at(&project_path).await
}
pub async fn spawn_shell_at(&self, path: &Path) -> anyhow::Result<()> {
if !path.exists() {
if !project_path.exists() {
anyhow::bail!(
"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")?;
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")?;

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"]