34 Commits

Author SHA1 Message Date
52b996145d 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-03-07 04:34:13 +00:00
38a51f3aa7 feat: add worktree / workspaces implementation
Some checks failed
continuous-integration/drone/pr Build encountered an error
continuous-integration/drone/push Build encountered an error
Signed-off-by: kjuulh <contact@kjuulh.io>
2026-02-28 17:12:22 +01:00
43aac23453 feat: install
Some checks failed
continuous-integration/drone/push Build encountered an error
2026-02-10 12:40:51 +01:00
f90dc1e8d5 feat: remove nightly features
Some checks failed
continuous-integration/drone/pr Build encountered an error
continuous-integration/drone/push Build encountered an error
2026-02-02 14:35:51 +01:00
aef7b65e0c feat: bogus commit
Some checks failed
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build encountered an error
2025-11-13 02:10:31 +01:00
313b971c74 feat: link to website 2025-11-13 02:04:51 +01:00
167d71024d fix(deps): update rust crate bytes to v1.10.1
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2025-03-06 01:41:55 +00:00
cuddle-please
e2102bfd99 chore(release): 0.3.5
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2025-03-04 12:16:38 +00:00
716adfab8b feat: allow clone all
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2025-03-04 13:11:37 +01:00
c77a9dcbef chore(deps): update all dependencies
Some checks failed
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is failing
2025-03-03 05:39:38 +00:00
7077c623ca chore(deps): update all dependencies
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2025-02-21 01:43:38 +00:00
80614fa667 fix(deps): update rust crate serde to v1.0.218
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2025-02-20 05:41:56 +00:00
6ff93b100a chore(deps): update rust crate clap to v4.5.29
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2025-02-13 05:36:34 +00:00
76b617fc2d fix(deps): update tokio-prost monorepo to v0.13.5
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2025-02-13 01:38:36 +00:00
d699432f14 fix(deps): update rust crate termwiz to 0.23.0
Some checks failed
renovate/artifacts Artifact file update failure
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2025-02-11 01:41:18 +00:00
5c46cca96f fix(deps): update all dependencies
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2025-02-06 01:41:02 +00:00
8d071981a3 fix(deps): update rust crate uuid to v1.13.0
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2025-02-05 05:43:47 +00:00
8f9e40b98f fix(deps): update all dependencies
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2025-02-04 01:41:54 +00:00
98d4402461 fix(deps): update rust crate async-trait to v0.1.86
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2025-02-01 05:40:38 +00:00
816a65a04c fix(deps): update rust crate uuid to v1.12.1
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2025-01-21 05:41:33 +00:00
d893e7e21a chore(deps): update rust crate clap to v4.5.27
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2025-01-21 01:38:57 +00:00
2a3c4fbb83 fix(deps): update rust crate octocrab to 0.43.0
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2025-01-18 01:38:50 +00:00
9d459f10d5 fix(deps): update rust crate uuid to v1.12.0
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2025-01-15 01:44:24 +00:00
5aa5702272 fix(deps): update rust crate dirs to v6
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2025-01-13 01:37:39 +00:00
45b45420f8 fix(deps): update rust crate uuid to v1.11.1
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2025-01-10 05:33:24 +00:00
b49da1ca71 chore(deps): update rust crate clap to v4.5.26
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2025-01-10 01:34:25 +00:00
3c63992632 chore(deps): update rust crate tokio to v1.43.0
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2025-01-09 01:36:46 +00:00
aaf9cd414e chore: update example
All checks were successful
continuous-integration/drone/push Build is passing
2025-01-08 23:03:41 +01:00
0f30aaadf5 chore(release): v0.3.4 (#57)
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
chore(release): 0.3.4

Co-authored-by: cuddle-please <bot@cuddle.sh>
Reviewed-on: https://git.front.kjuulh.io/kjuulh/gitnow/pulls/57
2025-01-08 22:55:40 +01:00
84941de931 fix: use correct post clone command
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2025-01-08 22:53:22 +01:00
aa1ef7041e fix: tests for config
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2025-01-08 22:49:13 +01:00
2f501c0efd feat/add-post-clone-command
Some checks failed
continuous-integration/drone/pr Build is failing
continuous-integration/drone/push Build is passing
2025-01-08 22:47:23 +01:00
962425c515 add ability to specify multiple commands
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2025-01-08 22:42:35 +01:00
708f8d6388 feat: add ability to specify custom command
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2025-01-08 22:32:39 +01:00
22 changed files with 2008 additions and 827 deletions

View File

@@ -6,6 +6,49 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [0.3.5] - 2025-03-04
### Added
- allow clone all
### Fixed
- *(deps)* update rust crate serde to v1.0.218
- *(deps)* update tokio-prost monorepo to v0.13.5
- *(deps)* update rust crate termwiz to 0.23.0
- *(deps)* update all dependencies
- *(deps)* update rust crate uuid to v1.13.0
- *(deps)* update all dependencies
- *(deps)* update rust crate async-trait to v0.1.86
- *(deps)* update rust crate uuid to v1.12.1
- *(deps)* update rust crate octocrab to 0.43.0
- *(deps)* update rust crate uuid to v1.12.0
- *(deps)* update rust crate dirs to v6
- *(deps)* update rust crate uuid to v1.11.1
### Other
- *(deps)* update all dependencies
- *(deps)* update all dependencies
- *(deps)* update rust crate clap to v4.5.29
- *(deps)* update rust crate clap to v4.5.27
- *(deps)* update rust crate clap to v4.5.26
- *(deps)* update rust crate tokio to v1.43.0
- update example
## [0.3.4] - 2025-01-08
### Added
- feat/add-post-clone-command
- add ability to specify custom command
### Fixed
- use correct post clone command
- tests for config
### Other
- add ability to specify multiple commands
## [0.3.3] - 2025-01-07
### Other

1553
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -3,7 +3,7 @@ members = ["crates/*"]
resolver = "2"
[workspace.package]
version = "0.3.3"
version = "0.3.5"
[workspace.dependencies]

View File

@@ -1,6 +1,6 @@
# Git Now
Some example change
> 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.
@@ -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`.

View File

@@ -1,7 +1,7 @@
[package]
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."
edition = "2021"
edition = "2024"
readme = "../../README.md"
repository = "https://github.com/kjuulh/gitnow"
homepage = "https://gitnow-client.prod.kjuulh.app"
@@ -22,20 +22,23 @@ 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.42.0"
dirs = "5.0.1"
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.22.0"
termwiz = "0.23.0"
regex = "1.11.1"
minijinja = "2"
shell-words = "1"
[dev-dependencies]
pretty_assertions = "1.4.0"

View File

@@ -1,3 +1,84 @@
pub mod root;
pub mod shell;
pub mod update;
pub mod worktree;
pub mod clone {
use std::sync::Arc;
use futures::future::join_all;
use regex::Regex;
use crate::{
app::App, cache::CacheApp, custom_command::CustomCommandApp, git_clone::GitCloneApp,
};
#[derive(clap::Parser)]
pub struct CloneCommand {
#[arg(long = "search")]
search: String,
}
impl CloneCommand {
pub async fn execute(&mut self, app: &'static App) -> anyhow::Result<()> {
let repos = app.cache().get().await?.ok_or(anyhow::anyhow!(
"failed to get cache, do a gitnow update first"
))?;
let search = Regex::new(&self.search)?;
let filtered_repos = repos
.iter()
.filter(|r| search.is_match(&r.to_rel_path().display().to_string()))
.collect::<Vec<_>>();
let concurrency_limit = Arc::new(tokio::sync::Semaphore::new(5));
let mut handles = Vec::default();
for repo in filtered_repos {
let config = app.config.clone();
let custom_command = app.custom_command();
let git_clone = app.git_clone();
let repo = repo.clone();
let concurrency = Arc::clone(&concurrency_limit);
let handle = tokio::spawn(async move {
let permit = concurrency.acquire().await?;
let project_path = config.settings.projects.directory.join(repo.to_rel_path());
if !project_path.exists() {
eprintln!("cloning repository: {}", repo.to_rel_path().display());
git_clone.clone_repo(&repo, false).await?;
custom_command
.execute_post_clone_command(&project_path)
.await?;
}
drop(permit);
Ok::<(), anyhow::Error>(())
});
handles.push(handle);
}
let res = join_all(handles).await;
for res in res {
match res {
Ok(Ok(())) => {}
Ok(Err(e)) => {
tracing::error!("failed to clone repo: {}", e);
anyhow::bail!(e)
}
Err(e) => {
tracing::error!("failed to clone repo: {}", e);
anyhow::bail!(e)
}
}
}
Ok(())
}
}
}

View File

@@ -4,6 +4,7 @@ use crate::{
app::App,
cache::CacheApp,
components::inline_command::InlineCommand,
custom_command::CustomCommandApp,
fuzzy_matcher::{FuzzyMatcher, FuzzyMatcherApp},
git_clone::GitCloneApp,
git_provider::Repository,
@@ -110,12 +111,22 @@ impl RootCommand {
} else {
eprintln!("cloning repository...");
git_clone.clone_repo(&repo, force_refresh).await?;
self.app
.custom_command()
.execute_post_clone_command(&project_path)
.await?;
}
} else {
tracing::info!("skipping clone for repo: {}", &repo.to_rel_path().display());
}
} else {
tracing::info!("repository already exists");
self.app
.custom_command()
.execute_post_update_command(&project_path)
.await?;
}
if shell {

View File

@@ -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<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

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

View File

@@ -23,7 +23,7 @@ impl<'a> Spinner<'a> {
}
}
impl<'a> StatefulWidget for Spinner<'a> {
impl StatefulWidget for Spinner<'_> {
type State = SpinnerState;
fn render(

View File

@@ -3,7 +3,7 @@ use std::path::{Path, PathBuf};
use anyhow::Context;
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize, PartialEq)]
#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
pub struct Config {
#[serde(default)]
pub settings: Settings,
@@ -12,16 +12,74 @@ pub struct Config {
pub providers: Providers,
}
#[derive(Debug, Default, Serialize, Deserialize, PartialEq)]
#[derive(Debug, Default, Serialize, Deserialize, PartialEq, Clone)]
pub struct Settings {
#[serde(default)]
pub projects: Projects,
#[serde(default)]
pub cache: Cache,
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, Default, Serialize, Deserialize, PartialEq)]
#[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)]
#[serde(untagged)]
pub enum PostUpdateCommand {
Single(String),
Multiple(Vec<String>),
}
impl PostUpdateCommand {
pub fn get_commands(&self) -> Vec<String> {
match self.clone() {
PostUpdateCommand::Single(item) => vec![item],
PostUpdateCommand::Multiple(items) => items,
}
}
}
#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
#[serde(untagged)]
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)]
pub struct Projects {
pub directory: ProjectLocation,
}
@@ -57,7 +115,7 @@ impl std::ops::Deref for ProjectLocation {
}
}
#[derive(Debug, Default, Serialize, Deserialize, PartialEq)]
#[derive(Debug, Default, Serialize, Deserialize, PartialEq, Clone)]
pub struct Cache {
#[serde(default)]
pub location: CacheLocation,
@@ -89,7 +147,7 @@ impl Default for CacheLocation {
}
}
#[derive(Debug, Serialize, Deserialize, PartialEq)]
#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
#[serde(untagged)]
pub enum CacheDuration {
Enabled(bool),
@@ -113,7 +171,7 @@ impl CacheDuration {
hours,
minutes,
} => Some(
std::time::Duration::from_days(*days)
std::time::Duration::from_hours(*days * 24)
+ std::time::Duration::from_hours(*hours)
+ std::time::Duration::from_mins(*minutes),
),
@@ -131,7 +189,7 @@ impl Default for CacheDuration {
}
}
#[derive(Debug, Default, Serialize, Deserialize, PartialEq)]
#[derive(Debug, Default, Serialize, Deserialize, PartialEq, Clone)]
pub struct Providers {
#[serde(default)]
pub github: Vec<GitHub>,
@@ -139,7 +197,7 @@ pub struct Providers {
pub gitea: Vec<Gitea>,
}
#[derive(Debug, Serialize, Deserialize, PartialEq)]
#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
pub struct GitHub {
#[serde(default)]
pub url: Option<String>,
@@ -155,7 +213,7 @@ pub struct GitHub {
pub organisations: Vec<GitHubOrganisation>,
}
#[derive(Debug, Serialize, Deserialize, PartialEq)]
#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
pub struct GitHubUser(String);
impl From<GitHubUser> for String {
@@ -170,7 +228,7 @@ impl<'a> From<&'a GitHubUser> for &'a str {
}
}
#[derive(Debug, Serialize, Deserialize, PartialEq)]
#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
pub struct GitHubOrganisation(String);
impl From<GitHubOrganisation> for String {
@@ -185,7 +243,7 @@ impl<'a> From<&'a GitHubOrganisation> for &'a str {
}
}
#[derive(Debug, Serialize, Deserialize, PartialEq)]
#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
pub struct Gitea {
pub url: String,
@@ -201,21 +259,21 @@ pub struct Gitea {
pub organisations: Vec<GiteaOrganisation>,
}
#[derive(Debug, Serialize, Deserialize, PartialEq)]
#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
#[serde(untagged)]
pub enum GiteaAccessToken {
Direct(String),
Env { env: String },
}
#[derive(Debug, Serialize, Deserialize, PartialEq)]
#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
#[serde(untagged)]
pub enum GitHubAccessToken {
Direct(String),
Env { env: String },
}
#[derive(Debug, Serialize, Deserialize, PartialEq)]
#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
pub struct GiteaUser(String);
impl From<GiteaUser> for String {
@@ -230,7 +288,7 @@ impl<'a> From<&'a GiteaUser> for &'a str {
}
}
#[derive(Debug, Serialize, Deserialize, PartialEq)]
#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
pub struct GiteaOrganisation(String);
impl From<GiteaOrganisation> for String {
fn from(value: GiteaOrganisation) -> Self {
@@ -361,7 +419,11 @@ mod test {
},
projects: Projects {
directory: PathBuf::from("git").into()
}
},
post_update_command: None,
post_clone_command: None,
clone_command: None,
worktree: None,
}
},
config
@@ -386,7 +448,11 @@ mod test {
},
settings: Settings {
cache: Cache::default(),
projects: Projects::default()
projects: Projects::default(),
post_update_command: None,
post_clone_command: None,
clone_command: None,
worktree: None,
}
},
config
@@ -394,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(())
}
}

View File

@@ -0,0 +1,83 @@
use std::path::Path;
use crate::{app::App, config::Config};
pub struct CustomCommand {
config: Config,
}
impl CustomCommand {
pub fn new(config: Config) -> Self {
Self { config }
}
pub async fn execute_post_clone_command(&self, path: &Path) -> anyhow::Result<()> {
if let Some(post_clone_command) = &self.config.settings.post_clone_command {
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(())
}
pub async fn execute_post_update_command(&self, path: &Path) -> anyhow::Result<()> {
if let Some(post_update_command) = &self.config.settings.post_update_command {
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(())
}
}
pub trait CustomCommandApp {
fn custom_command(&self) -> CustomCommand;
}
impl CustomCommandApp for App {
fn custom_command(&self) -> CustomCommand {
CustomCommand::new(self.config.clone())
}
}

View File

@@ -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 {}",

View File

@@ -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<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)?;
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<Repository>,
matched_items: Vec<T>,
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<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_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<TermwizBackend>,
) -> anyhow::Result<Option<Repository>> {
self.update_matched_repos();
) -> anyhow::Result<Option<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 {
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<String> =
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::<Vec<_>>();
let list_items: Vec<ListItem> =
display_items.into_iter().map(ListItem::from).collect();
let repo_list_items = repo_items
.into_iter()
.map(ListItem::from)
.collect::<Vec<_>>();
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(),

View File

@@ -1,10 +1,11 @@
#![feature(duration_constructors)]
use std::path::PathBuf;
use anyhow::Context;
use clap::{Parser, Subcommand};
use commands::{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;
@@ -15,12 +16,15 @@ mod cache_codec;
mod commands;
mod components;
mod config;
mod custom_command;
mod fuzzy_matcher;
mod git_clone;
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"))]
@@ -28,6 +32,10 @@ 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>,
@@ -51,6 +59,8 @@ struct Command {
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) => {
@@ -88,6 +102,12 @@ async fn main() -> anyhow::Result<()> {
Commands::Update(mut update) => {
update.execute(app).await?;
}
Commands::Clone(mut clone) => {
clone.execute(app).await?;
}
Commands::Worktree(mut wt) => {
wt.execute(app).await?;
}
},
None => {
RootCommand::new(app)

View File

@@ -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")?;

View File

@@ -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<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

@@ -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: <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

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

View File

@@ -1,7 +1,31 @@
[settings]
# Runs after a project is fetched for the first time, either a single string, or multiple in an array
post_clone_command = "jj git init --colocate"
# Runs after a project is jumped to if it already exists.
post_update_command = ["git pull", "jj git fetch"]
[[providers.github]]
# Who is the user running the clone command
current_user = "kjuulh"
# How to authenticate to github
access_token = { env = "GITHUB_ACCESS_TOKEN" }
# Which users to index
users = ["kjuulh"]
# Which organisations to index
organisations = ["lunarway"]
[[providers.gitea]]
# WHich gitea instance to authenticate against
url = "https://git.front.kjuulh.io/api/v1"
# How to authenticate to gitea
current_user = "kjuulh"
# WHich token to use to authenticate
access_token = { env = "GITEA_ACCESS_TOKEN" }
# Which users to index
users = ["kjuulh"]
# Which organisations to index
organisation = ["noorgplease"]

View File

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

6
mise.toml Normal file
View File

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