feat: add worktree / workspaces implementation
Signed-off-by: kjuulh <contact@kjuulh.io>
This commit was merged in pull request #91.
This commit is contained in:
17
Cargo.lock
generated
17
Cargo.lock
generated
@@ -776,6 +776,7 @@ dependencies = [
|
|||||||
"dotenvy",
|
"dotenvy",
|
||||||
"futures",
|
"futures",
|
||||||
"gitea-client",
|
"gitea-client",
|
||||||
|
"minijinja",
|
||||||
"nucleo-matcher",
|
"nucleo-matcher",
|
||||||
"octocrab",
|
"octocrab",
|
||||||
"pretty_assertions",
|
"pretty_assertions",
|
||||||
@@ -784,6 +785,7 @@ dependencies = [
|
|||||||
"ratatui",
|
"ratatui",
|
||||||
"regex",
|
"regex",
|
||||||
"serde",
|
"serde",
|
||||||
|
"shell-words",
|
||||||
"termwiz 0.23.3",
|
"termwiz 0.23.3",
|
||||||
"tokio",
|
"tokio",
|
||||||
"toml",
|
"toml",
|
||||||
@@ -1345,6 +1347,15 @@ dependencies = [
|
|||||||
"unicase",
|
"unicase",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "minijinja"
|
||||||
|
version = "2.16.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5c54f3bcc034dd74496b5ca929fd0b710186672d5ff0b0f255a9ceb259042ece"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "minimal-lexical"
|
name = "minimal-lexical"
|
||||||
version = "0.2.1"
|
version = "0.2.1"
|
||||||
@@ -2289,6 +2300,12 @@ dependencies = [
|
|||||||
"lazy_static",
|
"lazy_static",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "shell-words"
|
||||||
|
version = "1.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "shlex"
|
name = "shlex"
|
||||||
version = "1.3.0"
|
version = "1.3.0"
|
||||||
|
|||||||
72
README.md
72
README.md
@@ -38,3 +38,75 @@ With gitnow
|
|||||||
1. `git now`
|
1. `git now`
|
||||||
2. Enter parts of the project name and press enter
|
2. Enter parts of the project name and press enter
|
||||||
3. Your project is automatically downloaded if it doesn't exist in an opinionated path dir, and move you there.
|
3. Your project is automatically downloaded if it doesn't exist in an opinionated path dir, and move you there.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Configuration lives at `~/.config/gitnow/gitnow.toml` (override with `$GITNOW_CONFIG`).
|
||||||
|
|
||||||
|
### Custom clone command
|
||||||
|
|
||||||
|
By default gitnow uses `git clone`. You can override this with any command using a [minijinja](https://docs.rs/minijinja) template:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[settings]
|
||||||
|
# Use jj (Jujutsu) instead of git
|
||||||
|
clone_command = "jj git clone {{ ssh_url }} {{ path }}"
|
||||||
|
```
|
||||||
|
|
||||||
|
Available template variables: `ssh_url`, `path`.
|
||||||
|
|
||||||
|
### Worktrees
|
||||||
|
|
||||||
|
gitnow supports git worktrees (or jj workspaces) via the `worktree` subcommand. This uses bare repositories so each branch gets its own directory as a sibling:
|
||||||
|
|
||||||
|
```
|
||||||
|
~/git/github.com/owner/repo/
|
||||||
|
├── .bare/ # bare clone (git clone --bare)
|
||||||
|
├── main/ # worktree for main branch
|
||||||
|
├── feature-login/ # worktree for feature/login branch
|
||||||
|
└── fix-typo/ # worktree for fix/typo branch
|
||||||
|
```
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Interactive: pick repo, then pick branch
|
||||||
|
gitnow worktree
|
||||||
|
|
||||||
|
# Pre-filter repo
|
||||||
|
gitnow worktree myproject
|
||||||
|
|
||||||
|
# Specify branch directly
|
||||||
|
gitnow worktree myproject -b feature/login
|
||||||
|
|
||||||
|
# Print worktree path instead of entering a shell
|
||||||
|
gitnow worktree myproject -b main --no-shell
|
||||||
|
```
|
||||||
|
|
||||||
|
All worktree commands are configurable via minijinja templates:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[settings.worktree]
|
||||||
|
# Default: "git clone --bare {{ ssh_url }} {{ bare_path }}"
|
||||||
|
clone_command = "git clone --bare {{ ssh_url }} {{ bare_path }}"
|
||||||
|
|
||||||
|
# Default: "git -C {{ bare_path }} worktree add {{ worktree_path }} {{ branch }}"
|
||||||
|
add_command = "git -C {{ bare_path }} worktree add {{ worktree_path }} {{ branch }}"
|
||||||
|
|
||||||
|
# Default: "git -C {{ bare_path }} branch --format=%(refname:short)"
|
||||||
|
list_branches_command = "git -C {{ bare_path }} branch --format=%(refname:short)"
|
||||||
|
```
|
||||||
|
|
||||||
|
For jj, you might use:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[settings]
|
||||||
|
clone_command = "jj git clone {{ ssh_url }} {{ path }}"
|
||||||
|
|
||||||
|
[settings.worktree]
|
||||||
|
clone_command = "jj git clone {{ ssh_url }} {{ bare_path }}"
|
||||||
|
add_command = "jj -R {{ bare_path }} workspace add --name {{ branch }} {{ worktree_path }}"
|
||||||
|
list_branches_command = "jj -R {{ bare_path }} bookmark list -T 'name ++ \"\\n\"'"
|
||||||
|
```
|
||||||
|
|
||||||
|
Available template variables for worktree commands: `bare_path`, `worktree_path`, `branch`, `ssh_url`.
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ crossterm = { version = "0.28.0", features = ["event-stream"] }
|
|||||||
futures = "0.3.30"
|
futures = "0.3.30"
|
||||||
termwiz = "0.23.0"
|
termwiz = "0.23.0"
|
||||||
regex = "1.11.1"
|
regex = "1.11.1"
|
||||||
|
minijinja = "2"
|
||||||
|
shell-words = "1"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
pretty_assertions = "1.4.0"
|
pretty_assertions = "1.4.0"
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
pub mod root;
|
pub mod root;
|
||||||
pub mod shell;
|
pub mod shell;
|
||||||
pub mod update;
|
pub mod update;
|
||||||
|
pub mod worktree;
|
||||||
pub mod clone {
|
pub mod clone {
|
||||||
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|||||||
166
crates/gitnow/src/commands/worktree.rs
Normal file
166
crates/gitnow/src/commands/worktree.rs
Normal 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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,6 +22,29 @@ pub struct Settings {
|
|||||||
|
|
||||||
pub post_clone_command: Option<PostCloneCommand>,
|
pub post_clone_command: Option<PostCloneCommand>,
|
||||||
pub post_update_command: Option<PostUpdateCommand>,
|
pub post_update_command: Option<PostUpdateCommand>,
|
||||||
|
|
||||||
|
/// Minijinja template for the clone command.
|
||||||
|
/// Default: "git clone {{ ssh_url }} {{ path }}"
|
||||||
|
pub clone_command: Option<String>,
|
||||||
|
|
||||||
|
/// Worktree configuration.
|
||||||
|
#[serde(default)]
|
||||||
|
pub worktree: Option<WorktreeSettings>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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)]
|
#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
|
||||||
@@ -398,7 +421,9 @@ mod test {
|
|||||||
directory: PathBuf::from("git").into()
|
directory: PathBuf::from("git").into()
|
||||||
},
|
},
|
||||||
post_update_command: None,
|
post_update_command: None,
|
||||||
post_clone_command: None
|
post_clone_command: None,
|
||||||
|
clone_command: None,
|
||||||
|
worktree: None,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
config
|
config
|
||||||
@@ -425,7 +450,9 @@ mod test {
|
|||||||
cache: Cache::default(),
|
cache: Cache::default(),
|
||||||
projects: Projects::default(),
|
projects: Projects::default(),
|
||||||
post_update_command: None,
|
post_update_command: None,
|
||||||
post_clone_command: None
|
post_clone_command: None,
|
||||||
|
clone_command: None,
|
||||||
|
worktree: None,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
config
|
config
|
||||||
@@ -433,4 +460,54 @@ mod test {
|
|||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_can_parse_config_with_clone_command() -> anyhow::Result<()> {
|
||||||
|
let content = r#"
|
||||||
|
[settings]
|
||||||
|
projects = { directory = "git" }
|
||||||
|
clone_command = "jj git clone {{ ssh_url }} {{ path }}"
|
||||||
|
"#;
|
||||||
|
|
||||||
|
let config = Config::from_string(content)?;
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
config.settings.clone_command,
|
||||||
|
Some("jj git clone {{ ssh_url }} {{ path }}".to_string())
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_can_parse_config_with_worktree() -> anyhow::Result<()> {
|
||||||
|
let content = r#"
|
||||||
|
[settings]
|
||||||
|
projects = { directory = "git" }
|
||||||
|
|
||||||
|
[settings.worktree]
|
||||||
|
clone_command = "jj git clone {{ ssh_url }} {{ bare_path }}"
|
||||||
|
add_command = "jj workspace add --name {{ branch }} {{ worktree_path }}"
|
||||||
|
list_branches_command = "jj -R {{ bare_path }} branch list"
|
||||||
|
"#;
|
||||||
|
|
||||||
|
let config = Config::from_string(content)?;
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
config.settings.worktree,
|
||||||
|
Some(WorktreeSettings {
|
||||||
|
clone_command: Some(
|
||||||
|
"jj git clone {{ ssh_url }} {{ bare_path }}".to_string()
|
||||||
|
),
|
||||||
|
add_command: Some(
|
||||||
|
"jj workspace add --name {{ branch }} {{ worktree_path }}".to_string()
|
||||||
|
),
|
||||||
|
list_branches_command: Some(
|
||||||
|
"jj -R {{ bare_path }} branch list".to_string()
|
||||||
|
),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct GitClone {
|
pub struct GitClone {
|
||||||
@@ -35,20 +37,27 @@ impl GitClone {
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let template = self
|
||||||
|
.app
|
||||||
|
.config
|
||||||
|
.settings
|
||||||
|
.clone_command
|
||||||
|
.as_deref()
|
||||||
|
.unwrap_or(template_command::DEFAULT_CLONE_COMMAND);
|
||||||
|
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
"cloning: {} into {}",
|
"cloning: {} into {}",
|
||||||
repository.ssh_url.as_str(),
|
repository.ssh_url.as_str(),
|
||||||
&project_path.display().to_string(),
|
&project_path.display().to_string(),
|
||||||
);
|
);
|
||||||
|
|
||||||
let mut cmd = tokio::process::Command::new("git");
|
let path_str = project_path.display().to_string();
|
||||||
cmd.args([
|
let context = HashMap::from([
|
||||||
"clone",
|
("ssh_url", repository.ssh_url.as_str()),
|
||||||
repository.ssh_url.as_str(),
|
("path", path_str.as_str()),
|
||||||
&project_path.display().to_string(),
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
let output = cmd.output().await?;
|
let output = template_command::render_and_execute(template, context).await?;
|
||||||
match output.status.success() {
|
match output.status.success() {
|
||||||
true => tracing::debug!(
|
true => tracing::debug!(
|
||||||
"cloned {} into {}",
|
"cloned {} into {}",
|
||||||
|
|||||||
@@ -3,6 +3,25 @@ use ratatui::{prelude::*, Terminal};
|
|||||||
|
|
||||||
use crate::git_provider::Repository;
|
use crate::git_provider::Repository;
|
||||||
|
|
||||||
|
pub trait Searchable: Clone {
|
||||||
|
fn display_label(&self) -> String;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Searchable for Repository {
|
||||||
|
fn display_label(&self) -> String {
|
||||||
|
self.to_rel_path().display().to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct StringItem(pub String);
|
||||||
|
|
||||||
|
impl Searchable for StringItem {
|
||||||
|
fn display_label(&self) -> String {
|
||||||
|
self.0.clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub struct Interactive {
|
pub struct Interactive {
|
||||||
app: &'static crate::app::App,
|
app: &'static crate::app::App,
|
||||||
}
|
}
|
||||||
@@ -16,12 +35,17 @@ impl Interactive {
|
|||||||
&mut self,
|
&mut self,
|
||||||
repositories: &[Repository],
|
repositories: &[Repository],
|
||||||
) -> anyhow::Result<Option<Repository>> {
|
) -> anyhow::Result<Option<Repository>> {
|
||||||
|
self.interactive_search_items(repositories)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn interactive_search_items<T: Searchable>(
|
||||||
|
&mut self,
|
||||||
|
items: &[T],
|
||||||
|
) -> anyhow::Result<Option<T>> {
|
||||||
let backend = TermwizBackend::new().map_err(|e| anyhow::anyhow!(e.to_string()))?;
|
let backend = TermwizBackend::new().map_err(|e| anyhow::anyhow!(e.to_string()))?;
|
||||||
let terminal = Terminal::new(backend)?;
|
let terminal = Terminal::new(backend)?;
|
||||||
|
|
||||||
let app_result = App::new(self.app, repositories).run(terminal);
|
App::new(self.app, items).run(terminal)
|
||||||
|
|
||||||
app_result
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,36 +71,47 @@ mod app {
|
|||||||
Frame, Terminal,
|
Frame, Terminal,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::{
|
use crate::fuzzy_matcher::FuzzyMatcherApp;
|
||||||
commands::root::RepositoryMatcher, fuzzy_matcher::FuzzyMatcherApp, git_provider::Repository,
|
|
||||||
};
|
|
||||||
|
|
||||||
pub struct App<'a> {
|
use super::Searchable;
|
||||||
|
|
||||||
|
pub struct App<'a, T: Searchable> {
|
||||||
app: &'static crate::app::App,
|
app: &'static crate::app::App,
|
||||||
repositories: &'a [Repository],
|
items: &'a [T],
|
||||||
current_search: String,
|
current_search: String,
|
||||||
matched_repos: Vec<Repository>,
|
matched_items: Vec<T>,
|
||||||
list: ListState,
|
list: ListState,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<'a> App<'a> {
|
impl<'a, T: Searchable> App<'a, T> {
|
||||||
pub fn new(app: &'static crate::app::App, repositories: &'a [Repository]) -> Self {
|
pub fn new(app: &'static crate::app::App, items: &'a [T]) -> Self {
|
||||||
Self {
|
Self {
|
||||||
app,
|
app,
|
||||||
repositories,
|
items,
|
||||||
current_search: String::default(),
|
current_search: String::default(),
|
||||||
matched_repos: Vec::default(),
|
matched_items: Vec::default(),
|
||||||
list: ListState::default(),
|
list: ListState::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn update_matched_repos(&mut self) {
|
fn update_matched_items(&mut self) {
|
||||||
let res = 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
|
.app
|
||||||
.fuzzy_matcher()
|
.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() {
|
if self.list.selected().is_none() {
|
||||||
self.list.select_first();
|
self.list.select_first();
|
||||||
@@ -86,39 +121,41 @@ mod app {
|
|||||||
pub fn run(
|
pub fn run(
|
||||||
mut self,
|
mut self,
|
||||||
mut terminal: Terminal<TermwizBackend>,
|
mut terminal: Terminal<TermwizBackend>,
|
||||||
) -> anyhow::Result<Option<Repository>> {
|
) -> anyhow::Result<Option<T>> {
|
||||||
self.update_matched_repos();
|
self.update_matched_items();
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
terminal.draw(|frame| self.draw(frame))?;
|
terminal.draw(|frame| self.draw(frame))?;
|
||||||
|
|
||||||
if let Event::Key(key) = event::read()? {
|
if let Event::Key(key) = event::read()? {
|
||||||
if let KeyCode::Char('c') = key.code {
|
if let KeyCode::Char('c') = key.code
|
||||||
if key.modifiers.contains(KeyModifiers::CONTROL) {
|
&& key.modifiers.contains(KeyModifiers::CONTROL)
|
||||||
return Ok(None);
|
{
|
||||||
}
|
return Ok(None);
|
||||||
}
|
}
|
||||||
|
|
||||||
match key.code {
|
match key.code {
|
||||||
KeyCode::Char(letter) => {
|
KeyCode::Char(letter) => {
|
||||||
self.current_search.push(letter);
|
self.current_search.push(letter);
|
||||||
self.update_matched_repos();
|
self.update_matched_items();
|
||||||
}
|
}
|
||||||
KeyCode::Backspace => {
|
KeyCode::Backspace => {
|
||||||
if !self.current_search.is_empty() {
|
if !self.current_search.is_empty() {
|
||||||
let _ = self.current_search.remove(self.current_search.len() - 1);
|
let _ =
|
||||||
self.update_matched_repos();
|
self.current_search.remove(self.current_search.len() - 1);
|
||||||
|
self.update_matched_items();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
KeyCode::Esc => {
|
KeyCode::Esc => {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
KeyCode::Enter => {
|
KeyCode::Enter => {
|
||||||
if let Some(selected) = self.list.selected() {
|
if let Some(selected) = self.list.selected()
|
||||||
if let Some(repo) = self.matched_repos.get(selected).cloned() {
|
&& let Some(item) =
|
||||||
terminal.resize(ratatui::layout::Rect::ZERO)?;
|
self.matched_items.get(selected).cloned()
|
||||||
return Ok(Some(repo));
|
{
|
||||||
}
|
terminal.resize(ratatui::layout::Rect::ZERO)?;
|
||||||
|
return Ok(Some(item));
|
||||||
}
|
}
|
||||||
|
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
@@ -133,33 +170,22 @@ mod app {
|
|||||||
|
|
||||||
fn draw(&mut self, frame: &mut Frame) {
|
fn draw(&mut self, frame: &mut Frame) {
|
||||||
let vertical = Layout::vertical([Constraint::Percentage(100), Constraint::Min(1)]);
|
let vertical = Layout::vertical([Constraint::Percentage(100), Constraint::Min(1)]);
|
||||||
let [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
|
let list_items: Vec<ListItem> =
|
||||||
.iter()
|
display_items.into_iter().map(ListItem::from).collect();
|
||||||
.map(|r| r.to_rel_path().display().to_string())
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
|
|
||||||
let repo_list_items = repo_items
|
let list = ratatui::widgets::List::new(list_items)
|
||||||
.into_iter()
|
|
||||||
.map(ListItem::from)
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
|
|
||||||
let repo_list = ratatui::widgets::List::new(repo_list_items)
|
|
||||||
.direction(ratatui::widgets::ListDirection::BottomToTop)
|
.direction(ratatui::widgets::ListDirection::BottomToTop)
|
||||||
.scroll_padding(3)
|
.scroll_padding(3)
|
||||||
.highlight_symbol("> ")
|
.highlight_symbol("> ")
|
||||||
.highlight_spacing(ratatui::widgets::HighlightSpacing::Always)
|
.highlight_spacing(ratatui::widgets::HighlightSpacing::Always)
|
||||||
.highlight_style(Style::default().bold().white());
|
.highlight_style(Style::default().bold().white());
|
||||||
|
|
||||||
StatefulWidget::render(
|
StatefulWidget::render(list, list_area, frame.buffer_mut(), &mut self.list);
|
||||||
repo_list,
|
|
||||||
repository_area,
|
|
||||||
frame.buffer_mut(),
|
|
||||||
&mut self.list,
|
|
||||||
);
|
|
||||||
|
|
||||||
let input = Paragraph::new(Line::from(vec![
|
let input = Paragraph::new(Line::from(vec![
|
||||||
Span::from("> ").blue(),
|
Span::from("> ").blue(),
|
||||||
|
|||||||
@@ -2,7 +2,10 @@ use std::path::PathBuf;
|
|||||||
|
|
||||||
use anyhow::Context;
|
use anyhow::Context;
|
||||||
use clap::{Parser, Subcommand};
|
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 config::Config;
|
||||||
use tracing::level_filters::LevelFilter;
|
use tracing::level_filters::LevelFilter;
|
||||||
use tracing_subscriber::EnvFilter;
|
use tracing_subscriber::EnvFilter;
|
||||||
@@ -20,6 +23,8 @@ mod git_provider;
|
|||||||
mod interactive;
|
mod interactive;
|
||||||
mod projects_list;
|
mod projects_list;
|
||||||
mod shell;
|
mod shell;
|
||||||
|
mod template_command;
|
||||||
|
mod worktree;
|
||||||
|
|
||||||
#[derive(Parser)]
|
#[derive(Parser)]
|
||||||
#[command(author, version, about, long_about = Some("Navigate git projects at the speed of thought"))]
|
#[command(author, version, about, long_about = Some("Navigate git projects at the speed of thought"))]
|
||||||
@@ -27,6 +32,10 @@ struct Command {
|
|||||||
#[command(subcommand)]
|
#[command(subcommand)]
|
||||||
command: Option<Commands>,
|
command: Option<Commands>,
|
||||||
|
|
||||||
|
/// Path to config file (default: ~/.config/gitnow/gitnow.toml, or $GITNOW_CONFIG)
|
||||||
|
#[arg(long = "config", short = 'c', global = true)]
|
||||||
|
config: Option<PathBuf>,
|
||||||
|
|
||||||
#[arg()]
|
#[arg()]
|
||||||
search: Option<String>,
|
search: Option<String>,
|
||||||
|
|
||||||
@@ -51,6 +60,7 @@ enum Commands {
|
|||||||
Init(Shell),
|
Init(Shell),
|
||||||
Update(Update),
|
Update(Update),
|
||||||
Clone(CloneCommand),
|
Clone(CloneCommand),
|
||||||
|
Worktree(WorktreeCommand),
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_CONFIG_PATH: &str = ".config/gitnow/gitnow.toml";
|
const DEFAULT_CONFIG_PATH: &str = ".config/gitnow/gitnow.toml";
|
||||||
@@ -66,20 +76,24 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
)
|
)
|
||||||
.init();
|
.init();
|
||||||
|
|
||||||
let home =
|
let cli = Command::parse();
|
||||||
std::env::var("HOME").context("HOME was not found, are you using a proper shell?")?;
|
tracing::debug!("Starting cli");
|
||||||
let default_config_path = PathBuf::from(home).join(DEFAULT_CONFIG_PATH);
|
|
||||||
let config_path = std::env::var("GITNOW_CONFIG")
|
let config_path = if let Some(path) = &cli.config {
|
||||||
.map(PathBuf::from)
|
path.clone()
|
||||||
.unwrap_or(default_config_path);
|
} 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 config = Config::from_file(&config_path).await?;
|
||||||
|
|
||||||
let app = app::App::new_static(config).await?;
|
let app = app::App::new_static(config).await?;
|
||||||
|
|
||||||
let cli = Command::parse();
|
|
||||||
tracing::debug!("Starting cli");
|
|
||||||
|
|
||||||
match cli.command {
|
match cli.command {
|
||||||
Some(cmd) => match cmd {
|
Some(cmd) => match cmd {
|
||||||
Commands::Init(mut shell) => {
|
Commands::Init(mut shell) => {
|
||||||
@@ -91,6 +105,9 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
Commands::Clone(mut clone) => {
|
Commands::Clone(mut clone) => {
|
||||||
clone.execute(app).await?;
|
clone.execute(app).await?;
|
||||||
}
|
}
|
||||||
|
Commands::Worktree(mut wt) => {
|
||||||
|
wt.execute(app).await?;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
None => {
|
None => {
|
||||||
RootCommand::new(app)
|
RootCommand::new(app)
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
use std::path::Path;
|
||||||
|
|
||||||
use anyhow::Context;
|
use anyhow::Context;
|
||||||
|
|
||||||
use crate::{app::App, git_provider::Repository};
|
use crate::{app::App, git_provider::Repository};
|
||||||
@@ -20,10 +22,14 @@ impl Shell {
|
|||||||
.directory
|
.directory
|
||||||
.join(repository.to_rel_path());
|
.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!(
|
anyhow::bail!(
|
||||||
"project path: {} does not exists, it is either a file, or hasn't been cloned",
|
"project path: {} does not exists, it is either a file, or hasn't been cloned",
|
||||||
project_path.display()
|
path.display()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,7 +37,7 @@ impl Shell {
|
|||||||
.context("failed to find SHELL variable, required for spawning embedded shells")?;
|
.context("failed to find SHELL variable, required for spawning embedded shells")?;
|
||||||
|
|
||||||
let mut shell_cmd = tokio::process::Command::new(shell);
|
let mut shell_cmd = tokio::process::Command::new(shell);
|
||||||
shell_cmd.current_dir(project_path);
|
shell_cmd.current_dir(path);
|
||||||
|
|
||||||
let mut process = shell_cmd.spawn().context("failed to spawn child session")?;
|
let mut process = shell_cmd.spawn().context("failed to spawn child session")?;
|
||||||
|
|
||||||
|
|||||||
146
crates/gitnow/src/template_command.rs
Normal file
146
crates/gitnow/src/template_command.rs
Normal 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"
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
182
crates/gitnow/src/worktree.rs
Normal file
182
crates/gitnow/src/worktree.rs
Normal 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
34
examples/config/git-worktrees.toml
Normal file
34
examples/config/git-worktrees.toml
Normal 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"]
|
||||||
42
examples/config/jj-worktrees.toml
Normal file
42
examples/config/jj-worktrees.toml
Normal 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"]
|
||||||
Reference in New Issue
Block a user