Compare commits
3 Commits
renovate/t
...
v0.3.8-alp
| Author | SHA1 | Date | |
|---|---|---|---|
|
110597a21e
|
|||
|
2f0303aada
|
|||
|
451fbe1640
|
@@ -46,15 +46,19 @@ release:
|
|||||||
name_template: "v{{ .Version }}"
|
name_template: "v{{ .Version }}"
|
||||||
mode: keep-existing
|
mode: keep-existing
|
||||||
|
|
||||||
homebrew_casks:
|
brews:
|
||||||
- name: gitnow
|
- name: gitnow
|
||||||
binaries:
|
|
||||||
- gitnow
|
|
||||||
repository:
|
repository:
|
||||||
owner: kjuulh
|
owner: kjuulh
|
||||||
name: homebrew-tap
|
name: homebrew-tap
|
||||||
token: "{{ .Env.RELEASE_TOKEN }}"
|
token: "{{ .Env.RELEASE_TOKEN }}"
|
||||||
url:
|
directory: Formula
|
||||||
template: "https://git.kjuulh.io/kjuulh/gitnow/releases/download/{{ .Tag }}/{{ .ArtifactName }}"
|
url_template: "https://git.kjuulh.io/kjuulh/gitnow/releases/download/{{ .Tag }}/{{ .ArtifactName }}"
|
||||||
homepage: "https://gitnow-client.prod.kjuulh.app"
|
homepage: "https://gitnow-client.prod.kjuulh.app"
|
||||||
description: "Git Now is a utility for easily navigating git projects from common upstream providers."
|
description: "Git Now is a utility for easily navigating git projects from common upstream providers."
|
||||||
|
install: |
|
||||||
|
bin.install "gitnow"
|
||||||
|
caveats: |
|
||||||
|
To enable shell integration, add this to your .zshrc:
|
||||||
|
|
||||||
|
eval "$(gitnow init zsh)"
|
||||||
|
|||||||
24
README.md
24
README.md
@@ -30,7 +30,7 @@ cargo binstall gitnow
|
|||||||
gitnow
|
gitnow
|
||||||
|
|
||||||
# Or install gitnow scripts (in your .bashrc, .zshrc) this will use native shell commands to move you around
|
# Or install gitnow scripts (in your .bashrc, .zshrc) this will use native shell commands to move you around
|
||||||
eval $(gitnow init zsh)
|
eval "$(gitnow init zsh)"
|
||||||
git-now # Long
|
git-now # Long
|
||||||
gn # Short alias
|
gn # Short alias
|
||||||
```
|
```
|
||||||
@@ -137,17 +137,35 @@ gitnow project create my-feature
|
|||||||
# Create from a template
|
# Create from a template
|
||||||
gitnow project create my-feature -t default
|
gitnow project create my-feature -t default
|
||||||
|
|
||||||
|
# Create non-interactively with specific repos (fuzzy-matched)
|
||||||
|
gitnow project create my-feature --repos repo-a --repos repo-b --no-template --no-shell
|
||||||
|
|
||||||
# Open an existing project (interactive selection)
|
# Open an existing project (interactive selection)
|
||||||
gitnow project
|
gitnow project
|
||||||
|
|
||||||
# Open by name
|
# Open by name
|
||||||
gitnow project my-feature
|
gitnow project my-feature
|
||||||
|
|
||||||
# Add more repos to a project
|
# List all projects
|
||||||
|
gitnow project list
|
||||||
|
|
||||||
|
# List with repo details
|
||||||
|
gitnow project list --repos
|
||||||
|
|
||||||
|
# List as JSON (for scripting)
|
||||||
|
gitnow project list --repos --json
|
||||||
|
|
||||||
|
# Add more repos to a project (interactive)
|
||||||
gitnow project add my-feature
|
gitnow project add my-feature
|
||||||
|
|
||||||
|
# Add repos non-interactively
|
||||||
|
gitnow project add my-feature --repos repo-c --repos repo-d
|
||||||
|
|
||||||
# Delete a project
|
# Delete a project
|
||||||
gitnow project delete my-feature
|
gitnow project delete my-feature
|
||||||
|
|
||||||
|
# Delete without confirmation
|
||||||
|
gitnow project delete my-feature --force
|
||||||
```
|
```
|
||||||
|
|
||||||
Project directories live at `~/.gitnow/projects/` by default. Templates live at `~/.gitnow/templates/`. Both are configurable:
|
Project directories live at `~/.gitnow/projects/` by default. Templates live at `~/.gitnow/templates/`. Both are configurable:
|
||||||
@@ -165,7 +183,7 @@ Commands that navigate to a directory (`gitnow`, `gitnow project`, `gitnow proje
|
|||||||
The recommended way to use gitnow is with shell integration, which uses a **chooser file** to communicate the selected path back to your shell:
|
The recommended way to use gitnow is with shell integration, which uses a **chooser file** to communicate the selected path back to your shell:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
eval $(gitnow init zsh)
|
eval "$(gitnow init zsh)"
|
||||||
git-now # or gn
|
git-now # or gn
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
pub mod project;
|
pub mod project;
|
||||||
pub mod root;
|
pub mod root;
|
||||||
pub mod shell;
|
pub mod shell;
|
||||||
|
pub mod skill;
|
||||||
pub mod update;
|
pub mod update;
|
||||||
pub mod worktree;
|
pub mod worktree;
|
||||||
pub mod clone {
|
pub mod clone {
|
||||||
|
|||||||
@@ -7,12 +7,15 @@ use crate::{
|
|||||||
cache::load_repositories,
|
cache::load_repositories,
|
||||||
chooser::Chooser,
|
chooser::Chooser,
|
||||||
custom_command::CustomCommandApp,
|
custom_command::CustomCommandApp,
|
||||||
|
fuzzy_matcher::FuzzyMatcherApp,
|
||||||
interactive::{InteractiveApp, Searchable},
|
interactive::{InteractiveApp, Searchable},
|
||||||
project_metadata::{ProjectMetadata, RepoEntry},
|
project_metadata::{ProjectMetadata, RepoEntry},
|
||||||
shell::ShellApp,
|
shell::ShellApp,
|
||||||
template_command,
|
template_command,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
use super::root::RepositoryMatcher;
|
||||||
|
|
||||||
#[derive(clap::Parser)]
|
#[derive(clap::Parser)]
|
||||||
pub struct ProjectCommand {
|
pub struct ProjectCommand {
|
||||||
#[command(subcommand)]
|
#[command(subcommand)]
|
||||||
@@ -35,6 +38,8 @@ enum ProjectSubcommand {
|
|||||||
Add(ProjectAddCommand),
|
Add(ProjectAddCommand),
|
||||||
/// Delete an existing project
|
/// Delete an existing project
|
||||||
Delete(ProjectDeleteCommand),
|
Delete(ProjectDeleteCommand),
|
||||||
|
/// List all projects and their repositories
|
||||||
|
List(ProjectListCommand),
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(clap::Parser)]
|
#[derive(clap::Parser)]
|
||||||
@@ -47,6 +52,15 @@ pub struct ProjectCreateCommand {
|
|||||||
#[arg(long = "template", short = 't')]
|
#[arg(long = "template", short = 't')]
|
||||||
template: Option<String>,
|
template: Option<String>,
|
||||||
|
|
||||||
|
/// Skip template selection entirely (even if templates exist)
|
||||||
|
#[arg(long = "no-template", default_value = "false")]
|
||||||
|
no_template: bool,
|
||||||
|
|
||||||
|
/// Repositories to include (fuzzy-matched against the cache). Can be
|
||||||
|
/// specified multiple times: --repos foo --repos bar
|
||||||
|
#[arg(long = "repos", short = 'r')]
|
||||||
|
repos: Vec<String>,
|
||||||
|
|
||||||
/// Skip cache when fetching repositories
|
/// Skip cache when fetching repositories
|
||||||
#[arg(long = "no-cache", default_value = "false")]
|
#[arg(long = "no-cache", default_value = "false")]
|
||||||
no_cache: bool,
|
no_cache: bool,
|
||||||
@@ -62,11 +76,27 @@ pub struct ProjectAddCommand {
|
|||||||
#[arg()]
|
#[arg()]
|
||||||
name: Option<String>,
|
name: Option<String>,
|
||||||
|
|
||||||
|
/// Repositories to add (fuzzy-matched against the cache). Can be
|
||||||
|
/// specified multiple times: --repos foo --repos bar
|
||||||
|
#[arg(long = "repos", short = 'r')]
|
||||||
|
repos: Vec<String>,
|
||||||
|
|
||||||
/// Skip cache when fetching repositories
|
/// Skip cache when fetching repositories
|
||||||
#[arg(long = "no-cache", default_value = "false")]
|
#[arg(long = "no-cache", default_value = "false")]
|
||||||
no_cache: bool,
|
no_cache: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(clap::Parser)]
|
||||||
|
pub struct ProjectListCommand {
|
||||||
|
/// Show repository details for each project
|
||||||
|
#[arg(long = "repos", default_value = "false")]
|
||||||
|
repos: bool,
|
||||||
|
|
||||||
|
/// Output as JSON
|
||||||
|
#[arg(long = "json", default_value = "false")]
|
||||||
|
json: bool,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(clap::Parser)]
|
#[derive(clap::Parser)]
|
||||||
pub struct ProjectDeleteCommand {
|
pub struct ProjectDeleteCommand {
|
||||||
/// Project name to delete
|
/// Project name to delete
|
||||||
@@ -284,6 +314,7 @@ impl ProjectCommand {
|
|||||||
Some(ProjectSubcommand::Create(mut create)) => create.execute(app, chooser).await,
|
Some(ProjectSubcommand::Create(mut create)) => create.execute(app, chooser).await,
|
||||||
Some(ProjectSubcommand::Add(mut add)) => add.execute(app).await,
|
Some(ProjectSubcommand::Add(mut add)) => add.execute(app).await,
|
||||||
Some(ProjectSubcommand::Delete(mut delete)) => delete.execute(app).await,
|
Some(ProjectSubcommand::Delete(mut delete)) => delete.execute(app).await,
|
||||||
|
Some(ProjectSubcommand::List(list)) => list.execute(app).await,
|
||||||
None => self.open_existing(app, chooser).await,
|
None => self.open_existing(app, chooser).await,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -368,10 +399,25 @@ impl ProjectCreateCommand {
|
|||||||
|
|
||||||
let repositories = load_repositories(app, !self.no_cache).await?;
|
let repositories = load_repositories(app, !self.no_cache).await?;
|
||||||
|
|
||||||
eprintln!("Select repositories (Tab to toggle, Enter to confirm):");
|
let selected_repos = if !self.repos.is_empty() {
|
||||||
let selected_repos = app
|
let matcher = app.fuzzy_matcher();
|
||||||
.interactive()
|
let mut matched = Vec::new();
|
||||||
.interactive_multi_search(&repositories)?;
|
for needle in &self.repos {
|
||||||
|
let results = matcher.match_repositories(needle, &repositories);
|
||||||
|
let repo = results
|
||||||
|
.first()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("no repository matching '{}' found", needle))?
|
||||||
|
.to_owned();
|
||||||
|
if !matched.iter().any(|r: &crate::git_provider::Repository| r.ssh_url == repo.ssh_url) {
|
||||||
|
matched.push(repo);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
matched
|
||||||
|
} else {
|
||||||
|
eprintln!("Select repositories (Tab to toggle, Enter to confirm):");
|
||||||
|
app.interactive()
|
||||||
|
.interactive_multi_search(&repositories)?
|
||||||
|
};
|
||||||
|
|
||||||
if selected_repos.is_empty() {
|
if selected_repos.is_empty() {
|
||||||
anyhow::bail!("no repositories selected");
|
anyhow::bail!("no repositories selected");
|
||||||
@@ -383,29 +429,33 @@ impl ProjectCreateCommand {
|
|||||||
|
|
||||||
// Apply template if requested
|
// Apply template if requested
|
||||||
let templates_dir = get_templates_dir(app);
|
let templates_dir = get_templates_dir(app);
|
||||||
let template = match self.template.take() {
|
let template = if self.no_template {
|
||||||
Some(name) => {
|
None
|
||||||
let templates = list_subdirectories(&templates_dir)?;
|
} else {
|
||||||
Some(
|
match self.template.take() {
|
||||||
templates
|
Some(name) => {
|
||||||
.into_iter()
|
let templates = list_subdirectories(&templates_dir)?;
|
||||||
.find(|t| t.name == name)
|
Some(
|
||||||
.ok_or_else(|| {
|
templates
|
||||||
anyhow::anyhow!(
|
.into_iter()
|
||||||
"template '{}' not found in {}",
|
.find(|t| t.name == name)
|
||||||
name,
|
.ok_or_else(|| {
|
||||||
templates_dir.display()
|
anyhow::anyhow!(
|
||||||
)
|
"template '{}' not found in {}",
|
||||||
})?,
|
name,
|
||||||
)
|
templates_dir.display()
|
||||||
}
|
)
|
||||||
None => {
|
})?,
|
||||||
let templates = list_subdirectories(&templates_dir)?;
|
)
|
||||||
if !templates.is_empty() {
|
}
|
||||||
eprintln!("Select a project template (Esc to skip):");
|
None => {
|
||||||
app.interactive().interactive_search_items(&templates)?
|
let templates = list_subdirectories(&templates_dir)?;
|
||||||
} else {
|
if !templates.is_empty() {
|
||||||
None
|
eprintln!("Select a project template (Esc to skip):");
|
||||||
|
app.interactive().interactive_search_items(&templates)?
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -455,10 +505,25 @@ impl ProjectAddCommand {
|
|||||||
|
|
||||||
let repositories = load_repositories(app, !self.no_cache).await?;
|
let repositories = load_repositories(app, !self.no_cache).await?;
|
||||||
|
|
||||||
eprintln!("Select repositories to add (Tab to toggle, Enter to confirm):");
|
let selected_repos = if !self.repos.is_empty() {
|
||||||
let selected_repos = app
|
let matcher = app.fuzzy_matcher();
|
||||||
.interactive()
|
let mut matched = Vec::new();
|
||||||
.interactive_multi_search(&repositories)?;
|
for needle in &self.repos {
|
||||||
|
let results = matcher.match_repositories(needle, &repositories);
|
||||||
|
let repo = results
|
||||||
|
.first()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("no repository matching '{}' found", needle))?
|
||||||
|
.to_owned();
|
||||||
|
if !matched.iter().any(|r: &crate::git_provider::Repository| r.ssh_url == repo.ssh_url) {
|
||||||
|
matched.push(repo);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
matched
|
||||||
|
} else {
|
||||||
|
eprintln!("Select repositories to add (Tab to toggle, Enter to confirm):");
|
||||||
|
app.interactive()
|
||||||
|
.interactive_multi_search(&repositories)?
|
||||||
|
};
|
||||||
|
|
||||||
if selected_repos.is_empty() {
|
if selected_repos.is_empty() {
|
||||||
anyhow::bail!("no repositories selected");
|
anyhow::bail!("no repositories selected");
|
||||||
@@ -482,6 +547,86 @@ impl ProjectAddCommand {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl ProjectListCommand {
|
||||||
|
async fn execute(&self, app: &'static App) -> anyhow::Result<()> {
|
||||||
|
let projects_dir = get_projects_dir(app);
|
||||||
|
let projects = list_subdirectories(&projects_dir)?;
|
||||||
|
|
||||||
|
if projects.is_empty() {
|
||||||
|
if self.json {
|
||||||
|
println!("[]");
|
||||||
|
} else {
|
||||||
|
eprintln!(
|
||||||
|
"no projects found in {}. Use 'gitnow project create' to create one.",
|
||||||
|
projects_dir.display()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.json {
|
||||||
|
let mut entries = Vec::new();
|
||||||
|
for project in &projects {
|
||||||
|
let mut entry = serde_json::Map::new();
|
||||||
|
entry.insert(
|
||||||
|
"name".into(),
|
||||||
|
serde_json::Value::String(project.name.clone()),
|
||||||
|
);
|
||||||
|
entry.insert(
|
||||||
|
"path".into(),
|
||||||
|
serde_json::Value::String(project.path.display().to_string()),
|
||||||
|
);
|
||||||
|
if let Some(meta) = &project.metadata {
|
||||||
|
entry.insert(
|
||||||
|
"created_at".into(),
|
||||||
|
serde_json::Value::String(meta.created_at.to_rfc3339()),
|
||||||
|
);
|
||||||
|
if let Some(template) = &meta.template {
|
||||||
|
entry.insert(
|
||||||
|
"template".into(),
|
||||||
|
serde_json::Value::String(template.clone()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if self.repos {
|
||||||
|
let repos: Vec<serde_json::Value> = meta
|
||||||
|
.repositories
|
||||||
|
.iter()
|
||||||
|
.map(|r| {
|
||||||
|
serde_json::json!({
|
||||||
|
"provider": r.provider,
|
||||||
|
"owner": r.owner,
|
||||||
|
"repo_name": r.repo_name,
|
||||||
|
"ssh_url": r.ssh_url,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
entry.insert("repositories".into(), serde_json::Value::Array(repos));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
entries.push(serde_json::Value::Object(entry));
|
||||||
|
}
|
||||||
|
println!("{}", serde_json::to_string_pretty(&entries)?);
|
||||||
|
} else {
|
||||||
|
for project in &projects {
|
||||||
|
if let Some(meta) = &project.metadata {
|
||||||
|
println!("{} ({})", project.name, meta.created_ago());
|
||||||
|
} else {
|
||||||
|
println!("{}", project.name);
|
||||||
|
}
|
||||||
|
if self.repos {
|
||||||
|
if let Some(meta) = &project.metadata {
|
||||||
|
for repo in &meta.repositories {
|
||||||
|
println!(" {}/{}/{}", repo.provider, repo.owner, repo.repo_name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl ProjectDeleteCommand {
|
impl ProjectDeleteCommand {
|
||||||
async fn execute(&mut self, app: &'static App) -> anyhow::Result<()> {
|
async fn execute(&mut self, app: &'static App) -> anyhow::Result<()> {
|
||||||
let projects_dir = get_projects_dir(app);
|
let projects_dir = get_projects_dir(app);
|
||||||
|
|||||||
287
crates/gitnow/src/commands/skill.rs
Normal file
287
crates/gitnow/src/commands/skill.rs
Normal file
@@ -0,0 +1,287 @@
|
|||||||
|
/// The `skill` subcommand outputs a comprehensive, LLM-readable description of
|
||||||
|
/// everything gitnow can do — commands, flags, configuration, and workflows.
|
||||||
|
|
||||||
|
#[derive(clap::Parser)]
|
||||||
|
pub struct SkillCommand {}
|
||||||
|
|
||||||
|
impl SkillCommand {
|
||||||
|
pub async fn execute(&self) -> anyhow::Result<()> {
|
||||||
|
print!("{}", SKILL_TEXT);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const SKILL_TEXT: &str = r#"# gitnow — Navigate git projects at the speed of thought
|
||||||
|
|
||||||
|
gitnow is a CLI tool for discovering, cloning, and navigating git repositories
|
||||||
|
from multiple providers (GitHub, Gitea). It maintains a local cache of known
|
||||||
|
repositories and provides fuzzy-search, interactive selection, worktree
|
||||||
|
management, and scratch-pad project workspaces.
|
||||||
|
|
||||||
|
## Quick reference
|
||||||
|
|
||||||
|
```
|
||||||
|
gitnow [OPTIONS] [SEARCH] # search/clone/open a repository
|
||||||
|
gitnow update # refresh the local repository cache
|
||||||
|
gitnow clone --search <REGEX> # batch-clone repositories matching a pattern
|
||||||
|
gitnow worktree [SEARCH] [OPTIONS] # create and enter a git worktree for a branch
|
||||||
|
gitnow project [SEARCH] [OPTIONS] # open an existing scratch-pad project
|
||||||
|
gitnow project create [NAME] # create a new multi-repo project
|
||||||
|
gitnow project add [NAME] # add repositories to a project
|
||||||
|
gitnow project delete [NAME] # delete a project
|
||||||
|
gitnow project list [OPTIONS] # list all projects and their repos
|
||||||
|
gitnow init zsh # print zsh shell integration script
|
||||||
|
gitnow skill # print this reference (you are here)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Commands in detail
|
||||||
|
|
||||||
|
### Default (no subcommand)
|
||||||
|
|
||||||
|
```
|
||||||
|
gitnow [OPTIONS] [SEARCH]
|
||||||
|
```
|
||||||
|
|
||||||
|
Search for a repository, optionally clone it, and open a shell inside it.
|
||||||
|
|
||||||
|
- If SEARCH is provided, fuzzy-matches against cached repositories.
|
||||||
|
- If omitted, opens an interactive fuzzy-search picker.
|
||||||
|
- Clones the repository if it does not exist locally.
|
||||||
|
- Spawns a sub-shell in the repository directory.
|
||||||
|
|
||||||
|
**Flags:**
|
||||||
|
| Flag | Description |
|
||||||
|
|-----------------------|----------------------------------------------------------|
|
||||||
|
| `--no-cache` | Skip reading from the local cache; fetch fresh data |
|
||||||
|
| `--no-clone` | Do not clone the repository if it is missing locally |
|
||||||
|
| `--no-shell` | Print the path instead of spawning a shell |
|
||||||
|
| `--force-refresh` | Force a fresh clone even if the repo already exists |
|
||||||
|
| `--force-cache-update`| Update the cache before searching |
|
||||||
|
| `--chooser-file PATH` | Write selected path to this file (implies --no-shell) |
|
||||||
|
| `-c, --config PATH` | Path to config file (global flag) |
|
||||||
|
|
||||||
|
**Environment variables:**
|
||||||
|
- `GITNOW_CONFIG` — path to config file (overrides default)
|
||||||
|
- `GITNOW_CHOOSER_FILE` — equivalent to `--chooser-file`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `gitnow update`
|
||||||
|
|
||||||
|
Fetch all repositories from configured providers and update the local cache.
|
||||||
|
Should be run periodically or after adding new providers/organisations.
|
||||||
|
|
||||||
|
No flags.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `gitnow clone --search <REGEX>`
|
||||||
|
|
||||||
|
Batch-clone all repositories whose relative path matches the given regex.
|
||||||
|
Clones up to 5 repositories concurrently. Skips repos that already exist locally.
|
||||||
|
|
||||||
|
**Required flags:**
|
||||||
|
| Flag | Description |
|
||||||
|
|--------------------|--------------------------------------------|
|
||||||
|
| `--search <REGEX>` | Regular expression to match repository paths |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `gitnow worktree [SEARCH] [OPTIONS]`
|
||||||
|
|
||||||
|
Create a git worktree for a specific branch of a repository. This is useful for
|
||||||
|
working on multiple branches simultaneously without switching.
|
||||||
|
|
||||||
|
**Workflow:**
|
||||||
|
1. Select a repository (fuzzy search or interactive picker)
|
||||||
|
2. Bare-clone the repository if not already present
|
||||||
|
3. List remote branches
|
||||||
|
4. Select a branch (interactive picker, or `--branch`)
|
||||||
|
5. Create a worktree directory at `<project>/<sanitized-branch>/`
|
||||||
|
6. Spawn a shell in the worktree (or write path to chooser file)
|
||||||
|
|
||||||
|
**Flags:**
|
||||||
|
| Flag | Description |
|
||||||
|
|-------------------|------------------------------------------------|
|
||||||
|
| `[SEARCH]` | Optional search string to pre-filter repos |
|
||||||
|
| `-b, --branch` | Branch name (skips interactive branch picker) |
|
||||||
|
| `--no-cache` | Skip the local cache |
|
||||||
|
| `--no-shell` | Print path instead of spawning a shell |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `gitnow project [SEARCH] [OPTIONS]`
|
||||||
|
|
||||||
|
Manage scratch-pad projects — directories containing multiple cloned repositories,
|
||||||
|
optionally bootstrapped from a template.
|
||||||
|
|
||||||
|
When called without a subcommand, opens an existing project (interactive picker
|
||||||
|
or fuzzy match on SEARCH).
|
||||||
|
|
||||||
|
**Flags:**
|
||||||
|
| Flag | Description |
|
||||||
|
|--------------|------------------------------------------|
|
||||||
|
| `[SEARCH]` | Filter existing projects by name |
|
||||||
|
| `--no-shell` | Print path instead of spawning a shell |
|
||||||
|
|
||||||
|
#### `gitnow project create [NAME] [OPTIONS]`
|
||||||
|
|
||||||
|
Create a new project directory, select repositories to clone into it,
|
||||||
|
and optionally apply a template.
|
||||||
|
|
||||||
|
| Flag | Description |
|
||||||
|
|---------------------|------------------------------------------------------|
|
||||||
|
| `[NAME]` | Project name (prompted if omitted) |
|
||||||
|
| `-r, --repos` | Repositories to include (fuzzy-matched). Repeatable: `--repos foo --repos bar`. Skips interactive picker when provided. |
|
||||||
|
| `-t, --template` | Template name to bootstrap from |
|
||||||
|
| `--no-template` | Skip template selection entirely |
|
||||||
|
| `--no-cache` | Skip local cache when listing repos |
|
||||||
|
| `--no-shell` | Print path instead of spawning a shell |
|
||||||
|
|
||||||
|
Templates live in `~/.gitnow/templates/` (or the configured directory). Each
|
||||||
|
subdirectory is a template; its contents are copied into the new project.
|
||||||
|
|
||||||
|
**Non-interactive usage:**
|
||||||
|
```
|
||||||
|
gitnow project create my-feature --repos repo-a --repos repo-b --no-template --no-shell
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `gitnow project add [NAME] [OPTIONS]`
|
||||||
|
|
||||||
|
Add more repositories to an existing project.
|
||||||
|
|
||||||
|
| Flag | Description |
|
||||||
|
|--------------|------------------------------------------|
|
||||||
|
| `[NAME]` | Project name (interactive if omitted) |
|
||||||
|
| `-r, --repos`| Repositories to add (fuzzy-matched). Repeatable. Skips interactive picker when provided. |
|
||||||
|
| `--no-cache` | Skip local cache when listing repos |
|
||||||
|
|
||||||
|
**Non-interactive usage:**
|
||||||
|
```
|
||||||
|
gitnow project add my-feature --repos repo-c --repos repo-d
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `gitnow project list [OPTIONS]`
|
||||||
|
|
||||||
|
List all projects and optionally show their repositories.
|
||||||
|
|
||||||
|
| Flag | Description |
|
||||||
|
|--------------|------------------------------------------|
|
||||||
|
| `--repos` | Show repository details for each project |
|
||||||
|
| `--json` | Output as JSON |
|
||||||
|
|
||||||
|
#### `gitnow project delete [NAME] [OPTIONS]`
|
||||||
|
|
||||||
|
Delete a project directory.
|
||||||
|
|
||||||
|
| Flag | Description |
|
||||||
|
|---------------|--------------------------------------|
|
||||||
|
| `[NAME]` | Project name (interactive if omitted)|
|
||||||
|
| `-f, --force` | Skip confirmation prompt |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `gitnow init zsh`
|
||||||
|
|
||||||
|
Print a zsh shell integration script to stdout. Typically used as:
|
||||||
|
|
||||||
|
```zsh
|
||||||
|
eval "$(gitnow init zsh)"
|
||||||
|
```
|
||||||
|
|
||||||
|
This provides a shell function that changes directory after gitnow exits,
|
||||||
|
using the chooser-file mechanism.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `gitnow skill`
|
||||||
|
|
||||||
|
Print this LLM-readable reference document to stdout.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Config file location (in priority order):
|
||||||
|
1. `--config` / `-c` flag
|
||||||
|
2. `GITNOW_CONFIG` environment variable
|
||||||
|
3. `~/.config/gitnow/gitnow.toml`
|
||||||
|
|
||||||
|
### Config file format (TOML)
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[settings]
|
||||||
|
# Where repositories are cloned to (default: ~/git)
|
||||||
|
projects = { directory = "~/git" }
|
||||||
|
|
||||||
|
# Custom clone command (minijinja template)
|
||||||
|
# Available variables: {{ ssh_url }}, {{ path }}
|
||||||
|
# Default: "git clone {{ ssh_url }} {{ path }}"
|
||||||
|
clone_command = "git clone {{ ssh_url }} {{ path }}"
|
||||||
|
|
||||||
|
# Commands to run after cloning a repository
|
||||||
|
post_clone_command = "echo 'cloned!'"
|
||||||
|
# or as a list:
|
||||||
|
# post_clone_command = ["cmd1", "cmd2"]
|
||||||
|
|
||||||
|
# Commands to run when opening an already-cloned repository
|
||||||
|
post_update_command = "git fetch --prune"
|
||||||
|
|
||||||
|
[settings.cache]
|
||||||
|
# Where the cache is stored (default: ~/.cache/gitnow)
|
||||||
|
location = "~/.cache/gitnow"
|
||||||
|
|
||||||
|
# Cache duration (default: 7 days). Set to false to disable.
|
||||||
|
duration = { days = 7, hours = 0, minutes = 0 }
|
||||||
|
|
||||||
|
[settings.worktree]
|
||||||
|
# Custom worktree commands (minijinja templates)
|
||||||
|
clone_command = "git clone --bare {{ ssh_url }} {{ bare_path }}"
|
||||||
|
add_command = "git -C {{ bare_path }} worktree add {{ worktree_path }} {{ branch }}"
|
||||||
|
list_branches_command = "git -C {{ bare_path }} branch -r --format=%(refname:short)"
|
||||||
|
|
||||||
|
[settings.project]
|
||||||
|
# Where scratch-pad projects are stored (default: ~/.gitnow/projects)
|
||||||
|
directory = "~/.gitnow/projects"
|
||||||
|
# Where project templates live (default: ~/.gitnow/templates)
|
||||||
|
templates_directory = "~/.gitnow/templates"
|
||||||
|
|
||||||
|
# --- Providers ---
|
||||||
|
|
||||||
|
[[providers.github]]
|
||||||
|
access_token = "ghp_..." # or { env = "GITHUB_TOKEN" }
|
||||||
|
current_user = "your-username" # optional, for user-specific repos
|
||||||
|
users = ["user1"] # fetch repos for these users
|
||||||
|
organisations = ["org1", "org2"] # fetch repos for these orgs
|
||||||
|
url = "https://api.github.com" # optional, for GitHub Enterprise
|
||||||
|
|
||||||
|
[[providers.gitea]]
|
||||||
|
url = "https://gitea.example.com/api/v1"
|
||||||
|
access_token = "token" # or { env = "GITEA_TOKEN" }
|
||||||
|
current_user = "your-username"
|
||||||
|
users = ["user1"]
|
||||||
|
organisations = ["org1"]
|
||||||
|
```
|
||||||
|
|
||||||
|
Multiple provider entries are supported — gitnow aggregates repositories from all of them.
|
||||||
|
|
||||||
|
## Typical workflows
|
||||||
|
|
||||||
|
### First-time setup
|
||||||
|
1. Create `~/.config/gitnow/gitnow.toml` with at least one provider
|
||||||
|
2. Run `gitnow update` to populate the cache
|
||||||
|
3. Run `gitnow` to interactively search and clone a repo
|
||||||
|
|
||||||
|
### Daily use
|
||||||
|
- `gitnow <partial-name>` — jump to a repo by fuzzy name
|
||||||
|
- `gitnow worktree <repo> -b feature-x` — start work on a branch in a worktree
|
||||||
|
- `gitnow project create my-feature` — set up a multi-repo workspace
|
||||||
|
|
||||||
|
### Shell integration (zsh)
|
||||||
|
Add to `.zshrc`:
|
||||||
|
```zsh
|
||||||
|
eval "$(gitnow init zsh)"
|
||||||
|
```
|
||||||
|
This wraps gitnow so that selecting a repository changes your current shell's
|
||||||
|
working directory (instead of spawning a sub-shell).
|
||||||
|
"#;
|
||||||
@@ -317,6 +317,10 @@ pub mod multi_select {
|
|||||||
return Ok(Vec::new());
|
return Ok(Vec::new());
|
||||||
}
|
}
|
||||||
KeyCode::Enter => {
|
KeyCode::Enter => {
|
||||||
|
if self.selected_labels.is_empty() {
|
||||||
|
// Don't allow confirming with no selections
|
||||||
|
continue;
|
||||||
|
}
|
||||||
terminal.resize(ratatui::layout::Rect::ZERO)?;
|
terminal.resize(ratatui::layout::Rect::ZERO)?;
|
||||||
let selected: Vec<T> = self
|
let selected: Vec<T> = self
|
||||||
.items
|
.items
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ use std::path::PathBuf;
|
|||||||
use anyhow::Context;
|
use anyhow::Context;
|
||||||
use clap::{Parser, Subcommand};
|
use clap::{Parser, Subcommand};
|
||||||
use commands::{
|
use commands::{
|
||||||
clone::CloneCommand, project::ProjectCommand, root::RootCommand, shell::Shell, update::Update,
|
clone::CloneCommand, project::ProjectCommand, root::RootCommand, shell::Shell,
|
||||||
worktree::WorktreeCommand,
|
skill::SkillCommand, update::Update, worktree::WorktreeCommand,
|
||||||
};
|
};
|
||||||
use config::Config;
|
use config::Config;
|
||||||
use tracing::level_filters::LevelFilter;
|
use tracing::level_filters::LevelFilter;
|
||||||
@@ -29,7 +29,13 @@ mod template_command;
|
|||||||
mod worktree;
|
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"),
|
||||||
|
after_help = "TIP: LLM/AI agents should run `gitnow skill` for a complete machine-readable reference."
|
||||||
|
)]
|
||||||
struct Command {
|
struct Command {
|
||||||
#[command(subcommand)]
|
#[command(subcommand)]
|
||||||
command: Option<Commands>,
|
command: Option<Commands>,
|
||||||
@@ -71,6 +77,8 @@ enum Commands {
|
|||||||
Worktree(WorktreeCommand),
|
Worktree(WorktreeCommand),
|
||||||
/// Manage scratch-pad projects with multiple repositories
|
/// Manage scratch-pad projects with multiple repositories
|
||||||
Project(ProjectCommand),
|
Project(ProjectCommand),
|
||||||
|
/// Print an LLM-readable reference of all gitnow capabilities
|
||||||
|
Skill(SkillCommand),
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_CONFIG_PATH: &str = ".config/gitnow/gitnow.toml";
|
const DEFAULT_CONFIG_PATH: &str = ".config/gitnow/gitnow.toml";
|
||||||
@@ -131,6 +139,9 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
Commands::Project(mut project) => {
|
Commands::Project(mut project) => {
|
||||||
project.execute(app, &chooser).await?;
|
project.execute(app, &chooser).await?;
|
||||||
}
|
}
|
||||||
|
Commands::Skill(skill) => {
|
||||||
|
skill.execute().await?;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
None => {
|
None => {
|
||||||
RootCommand::new(app)
|
RootCommand::new(app)
|
||||||
|
|||||||
Reference in New Issue
Block a user