21 Commits

Author SHA1 Message Date
2da5223fdd feat: try again
All checks were successful
Release / release (push) Successful in 1m41s
Signed-off-by: kjuulh <contact@kjuulh.io>
2026-03-20 17:46:47 +01:00
e4440ff3a4 feat: try again
All checks were successful
Release / release (push) Successful in 1m44s
Signed-off-by: kjuulh <contact@kjuulh.io>
2026-03-20 17:41:55 +01:00
560f086656 feat: enable gitea
Some checks failed
Release / release (push) Failing after 6s
Signed-off-by: kjuulh <contact@kjuulh.io>
2026-03-20 17:40:07 +01:00
772aa1ea42 feat: replace dependencies and enable build on macos
Some checks failed
Release / release (push) Failing after 1m47s
Signed-off-by: kjuulh <contact@kjuulh.io>
2026-03-20 17:29:11 +01:00
f0b8ffdb9b feat: swap order
Some checks failed
Release / release (push) Failing after 2m37s
2026-03-20 17:12:01 +01:00
5813c5ca21 feat: use local package instead of octocrab
Some checks failed
Release / release (push) Failing after 2m54s
2026-03-20 17:05:36 +01:00
746ed6c9e3 feat: removed openssl
Some checks failed
Release / release (push) Failing after 3m7s
2026-03-20 16:27:27 +01:00
3e70200c66 feat: replace rsutls
Some checks failed
Release / release (push) Failing after 3m9s
2026-03-20 16:06:12 +01:00
a898d3ceac feat: update image
Some checks failed
Release / release (push) Failing after 3m21s
2026-03-20 15:53:36 +01:00
536157c5f5 feat: mise should run in build
Some checks failed
Release / release (push) Failing after 5s
2026-03-20 15:52:10 +01:00
6a49c1eedc fix: add ca-certificates and pkg-config to release Dockerfile
Some checks failed
Release / release (push) Failing after 1m10s
Add missing ca-certificates (for curl/mise install), pkg-config and
libssl-dev (for cargo builds). Remove redundant cargo tool from
mise.toml since it ships with rust.
2026-03-20 15:47:08 +01:00
fbedae4159 feat: add project metadata with .gitnow.json
Some checks failed
Release / release (push) Failing after 5s
Store creation time, template, and repository info in a .gitnow.json
file when projects are created via CLI. Project listing now shows
human-readable relative times (e.g. "3 days ago") and sorts by
creation date. Metadata is updated when adding repos to existing
projects and gracefully ignored for pre-existing projects.
2026-03-20 15:36:57 +01:00
b8424d595b fix: install mise to /usr/local/bin in release Dockerfile
Some checks failed
continuous-integration/drone/push Build encountered an error
Release / release (push) Failing after 6s
Use MISE_INSTALL_PATH to place mise on the default PATH, fixing
"mise: not found" in Docker builds.
2026-03-20 15:31:01 +01:00
e10ff5d9fc feat: add release Docker image and Gitea Actions workflow
Some checks failed
continuous-integration/drone/push Build encountered an error
Release / release (push) Failing after 1m6s
Add Dockerfile.release using mise to install toolchain, Gitea Actions
workflow with snapshot dry-run on main pushes and full release on tags,
and mise release-docker task for local testing.
2026-03-20 15:28:00 +01:00
be8faa6a36 feat: add goreleaser config for cross-platform releases
Some checks failed
continuous-integration/drone/push Build encountered an error
Set up GoReleaser with Rust builder (cargo-zigbuild) targeting
linux/darwin x amd64/arm64, Gitea release publishing, and Homebrew
cask generation. Add mise tasks for test, release, and snapshot builds.
2026-03-20 15:22:24 +01:00
f3cc24c9fc docs: add projects and shell integration sections to README
Some checks failed
continuous-integration/drone/push Build encountered an error
2026-03-20 14:41:54 +01:00
b5394a6b26 feat: add chooser file mechanism for shell directory switching
Some checks failed
continuous-integration/drone/push Build encountered an error
Replace stdout-based path capture with a temporary chooser file that the
shell wrapper reads after gitnow exits. Commands that select a directory
write to the file; commands that don't (e.g. project delete) leave it
empty, so the shell only cd's when appropriate. The chooser file path
can be set via --chooser-file flag or GITNOW_CHOOSER_FILE env var.
2026-03-20 14:39:42 +01:00
681993c379 refactor: eliminate code duplication across gitnow codebase
Some checks failed
continuous-integration/drone/push Build encountered an error
- Unify PostCloneCommand/PostUpdateCommand into single CommandList enum
- Add string_newtype! macro for config newtype wrappers
- Extract load_repositories() helper replacing 4 duplicated cache-or-fetch patterns
- Extract clone_repos_into() eliminating duplicated concurrent clone logic
- Merge TemplateEntry/ProjectEntry into single DirEntry struct
- Unify directory resolution and listing helpers in project command
- Simplify RepositoryMatcher, remove unused StringExt trait
2026-03-20 13:24:05 +01:00
bebb55e873 feat: add template bootstrapping for project create
Some checks failed
continuous-integration/drone/push Build encountered an error
Adds --template/-t flag to `gitnow project create` that copies files
from a template directory into new projects. Templates are discovered
from ~/.gitnow/templates/ (configurable via settings.project.templates_directory).
Includes a default template with a SPEC.md scaffold.
2026-03-20 13:08:15 +01:00
ad0f29826b feat: add project add command for cloning repos into existing projects
Some checks failed
continuous-integration/drone/push Build encountered an error
2026-03-20 13:03:06 +01:00
fe26d71266 feat: add project command for scratch-pad multi-repo workspaces
Some checks failed
continuous-integration/drone/push Build encountered an error
Adds `gitnow project` with create, delete, and open subcommands.
Create allows multi-selecting repositories to clone into a shared
project directory. Includes multi-select TUI support in interactive module.
2026-03-20 12:53:50 +01:00
23 changed files with 1953 additions and 1327 deletions

View File

@@ -0,0 +1,31 @@
name: Release
on:
push:
branches:
- main
tags:
- "v*"
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Build release image
run: docker build -f Dockerfile.release -t gitnow-release .
- name: Snapshot (dry run)
if: startsWith(github.ref, 'refs/tags/') == false
run: docker run --rm gitnow-release release-snapshot
- name: Release
if: startsWith(github.ref, 'refs/tags/')
run: |
docker run --rm \
-e GITEA_TOKEN=${{ secrets.RELEASE_TOKEN }} \
-e RELEASE_TOKEN=${{ secrets.RELEASE_TOKEN }} \
gitnow-release release

1
.gitignore vendored
View File

@@ -1,3 +1,4 @@
target/ target/
.cuddle/ .cuddle/
.DS_Store .DS_Store
dist/

60
.goreleaser.yaml Normal file
View File

@@ -0,0 +1,60 @@
version: 2
before:
hooks:
- cargo install --locked cargo-zigbuild
builds:
- id: gitnow
builder: rust
binary: gitnow
dir: .
targets:
- aarch64-apple-darwin
- x86_64-unknown-linux-gnu
- aarch64-unknown-linux-gnu
flags:
- --release
- -p=gitnow
archives:
- id: gitnow
name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}"
checksum:
name_template: "checksums.txt"
changelog:
sort: asc
filters:
exclude:
- "^docs:"
- "^test:"
- "^ci:"
gitea_urls:
api: https://git.kjuulh.io/api/v1
download: https://git.kjuulh.io
skip_tls_verify: false
release:
gitea:
owner: kjuulh
name: gitnow
draft: false
prerelease: auto
name_template: "v{{ .Version }}"
mode: keep-existing
homebrew_casks:
- name: gitnow
binaries:
- gitnow
repository:
owner: kjuulh
name: homebrew-tap
token: "{{ .Env.RELEASE_TOKEN }}"
url:
template: "https://git.kjuulh.io/kjuulh/gitnow/releases/download/{{ .Tag }}/{{ .ArtifactName }}"
homepage: "https://gitnow-client.prod.kjuulh.app"
description: "Git Now is a utility for easily navigating git projects from common upstream providers."

1436
Cargo.lock generated

File diff suppressed because it is too large Load Diff

23
Dockerfile.release Normal file
View File

@@ -0,0 +1,23 @@
FROM debian:trixie-slim
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
curl \
git \
build-essential \
&& rm -rf /var/lib/apt/lists/*
# Install mise to a location already on PATH
ENV MISE_INSTALL_PATH="/usr/local/bin/mise"
RUN curl https://mise.run | sh
WORKDIR /build
COPY mise.toml .
RUN mise trust && mise install
COPY . .
ENV MISE_YES=1
ENV MISE_TRUSTED_CONFIG_PATHS="/build"
ENTRYPOINT ["mise", "run"]
CMD ["release"]

View File

@@ -110,3 +110,71 @@ 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`. Available template variables for worktree commands: `bare_path`, `worktree_path`, `branch`, `ssh_url`.
### Projects
gitnow supports scratch-pad projects that group multiple repositories into a single directory. This is useful when working on features that span several repos.
```bash
# Create a new project (interactive repo selection)
gitnow project create my-feature
# Create from a template
gitnow project create my-feature -t default
# Open an existing project (interactive selection)
gitnow project
# Open by name
gitnow project my-feature
# Add more repos to a project
gitnow project add my-feature
# Delete a project
gitnow project delete my-feature
```
Project directories live at `~/.gitnow/projects/` by default. Templates live at `~/.gitnow/templates/`. Both are configurable:
```toml
[settings.project]
directory = "~/.gitnow/projects"
templates_directory = "~/.gitnow/templates"
```
Commands that navigate to a directory (`gitnow`, `gitnow project`, `gitnow project create`, `gitnow worktree`) will `cd` you there when using the shell integration. Commands that don't produce a path (`project add`, `project delete`, `update`) run normally without changing your directory.
### Shell integration
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
eval $(gitnow init zsh)
git-now # or gn
```
When you run `git-now`, the shell wrapper:
1. Creates a temporary chooser file
2. Runs `gitnow` with the `GITNOW_CHOOSER_FILE` env var pointing to it
3. If gitnow writes a path to the file, the wrapper `cd`s there
4. If the file is empty (e.g. after `git-now project delete`), no `cd` happens
This works uniformly for all subcommands:
```bash
git-now # pick a repo and cd there
git-now project # pick a project and cd there
git-now project create foo # create project and cd there
git-now project delete foo # deletes project, no cd
git-now worktree # pick repo+branch worktree, cd there
```
You can also set the chooser file manually for scripting:
```bash
GITNOW_CHOOSER_FILE=/tmp/choice gitnow project
# or
gitnow --chooser-file /tmp/choice project
```

View File

@@ -22,23 +22,24 @@ dotenvy.workspace = true
serde = { version = "1.0.197", features = ["derive"] } serde = { version = "1.0.197", features = ["derive"] }
uuid = { version = "1.7.0", features = ["v4"] } uuid = { version = "1.7.0", features = ["v4"] }
async-trait = "0.1.82" async-trait = "0.1.82"
toml = "1.0.0" toml = "0.8.19"
gitea-client = { version = "1.22.1" } reqwest = { version = "0.12", default-features = false, features = ["rustls-tls-webpki-roots", "json"] }
url = "2.5.2" url = "2.5.2"
octocrab = "0.49.0"
dirs = "6.0.0" dirs = "6.0.0"
prost = "0.13.2" prost = "0.13.2"
prost-types = "0.13.2" prost-types = "0.13.2"
bytes = "1.7.1" bytes = "1.7.1"
nucleo-matcher = "0.3.1" nucleo-matcher = "0.3.1"
ratatui = { version = "0.30.0", features = ["termwiz"] } ratatui = { version = "0.29.0", features = ["termwiz"] }
crossterm = { version = "0.29.0", features = ["event-stream"] } 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" minijinja = "2"
shell-words = "1" shell-words = "1"
chrono = { version = "0.4", default-features = false, features = ["std", "now", "serde"] }
serde_json = "1"
[dev-dependencies] [dev-dependencies]
pretty_assertions = "1.4.0" pretty_assertions = "1.4.0"

View File

@@ -4,19 +4,30 @@ function git-now {
nohup gitnow update > /dev/null 2>&1 & nohup gitnow update > /dev/null 2>&1 &
) )
# Find the repository of choice # Create a temporary chooser file
choice=$(gitnow "$@" --no-shell) local chooser_file
if [[ $? -ne 0 ]]; then chooser_file="$(mktemp)"
return $?
# Run gitnow with the chooser file
GITNOW_CHOOSER_FILE="$chooser_file" gitnow "$@"
local exit_code=$?
if [[ $exit_code -ne 0 ]]; then
rm -f "$chooser_file"
return $exit_code
fi fi
# Enter local repository path # If the chooser file has content, cd to the chosen path
cd "$(echo "$choice" | tail --lines 1)" if [[ -s "$chooser_file" ]]; then
local target
target="$(cat "$chooser_file")"
rm -f "$chooser_file"
cd "$target"
else
rm -f "$chooser_file"
fi
} }
function gn { function gn {
git-now "$@" git-now "$@"
if [[ $? -ne 0 ]]; then
return $?
fi
} }

View File

@@ -105,6 +105,22 @@ impl Cache {
} }
} }
/// Load repositories using the cache if available, otherwise fetch and update cache.
pub async fn load_repositories(app: &'static App, use_cache: bool) -> anyhow::Result<Vec<Repository>> {
use crate::projects_list::ProjectsListApp;
if use_cache {
if let Some(repos) = app.cache().get().await? {
return Ok(repos);
}
}
tracing::info!("fetching repositories...");
let repositories = app.projects_list().get_projects().await?;
app.cache().update(&repositories).await?;
Ok(repositories)
}
pub trait CacheApp { pub trait CacheApp {
fn cache(&self) -> Cache; fn cache(&self) -> Cache;
} }

View File

@@ -0,0 +1,34 @@
use std::path::{Path, PathBuf};
/// Manages an optional chooser file that the shell wrapper reads after gitnow
/// exits. When active, the selected directory path is written to the file
/// instead of being printed to stdout.
#[derive(Debug, Default)]
pub struct Chooser {
path: Option<PathBuf>,
}
impl Chooser {
pub fn new(path: PathBuf) -> Self {
Self { path: Some(path) }
}
/// Returns `true` when a chooser file has been configured.
pub fn is_active(&self) -> bool {
self.path.is_some()
}
/// Write `dir` to the chooser file. If no chooser file is configured the
/// path is printed to stdout (preserving the old `--no-shell` behaviour).
pub fn set(&self, dir: &Path) -> anyhow::Result<()> {
match &self.path {
Some(chooser_path) => {
std::fs::write(chooser_path, dir.display().to_string())?;
}
None => {
println!("{}", dir.display());
}
}
Ok(())
}
}

View File

@@ -1,3 +1,4 @@
pub mod project;
pub mod root; pub mod root;
pub mod shell; pub mod shell;
pub mod update; pub mod update;

View File

@@ -0,0 +1,515 @@
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use crate::{
app::App,
cache::load_repositories,
chooser::Chooser,
custom_command::CustomCommandApp,
interactive::{InteractiveApp, Searchable},
project_metadata::{ProjectMetadata, RepoEntry},
shell::ShellApp,
template_command,
};
#[derive(clap::Parser)]
pub struct ProjectCommand {
#[command(subcommand)]
command: Option<ProjectSubcommand>,
/// Search string to filter existing projects
#[arg()]
search: Option<String>,
/// Skip spawning a shell in the project directory
#[arg(long = "no-shell", default_value = "false")]
no_shell: bool,
}
#[derive(clap::Subcommand)]
enum ProjectSubcommand {
/// Create a new project with selected repositories
Create(ProjectCreateCommand),
/// Add repositories to an existing project
Add(ProjectAddCommand),
/// Delete an existing project
Delete(ProjectDeleteCommand),
}
#[derive(clap::Parser)]
pub struct ProjectCreateCommand {
/// Project name (will be used as directory name)
#[arg()]
name: Option<String>,
/// Bootstrap from a template in the templates directory
#[arg(long = "template", short = 't')]
template: Option<String>,
/// Skip cache when fetching repositories
#[arg(long = "no-cache", default_value = "false")]
no_cache: bool,
/// Skip spawning a shell in the project directory
#[arg(long = "no-shell", default_value = "false")]
no_shell: bool,
}
#[derive(clap::Parser)]
pub struct ProjectAddCommand {
/// Project name to add repositories to
#[arg()]
name: Option<String>,
/// Skip cache when fetching repositories
#[arg(long = "no-cache", default_value = "false")]
no_cache: bool,
}
#[derive(clap::Parser)]
pub struct ProjectDeleteCommand {
/// Project name to delete
#[arg()]
name: Option<String>,
/// Skip confirmation prompt
#[arg(long = "force", short = 'f', default_value = "false")]
force: bool,
}
// --- Shared helpers ---
/// A named directory entry usable in interactive search.
#[derive(Clone)]
struct DirEntry {
name: String,
path: PathBuf,
metadata: Option<ProjectMetadata>,
}
impl Searchable for DirEntry {
fn display_label(&self) -> String {
match &self.metadata {
Some(meta) => format!("{} ({})", self.name, meta.created_ago()),
None => self.name.clone(),
}
}
}
/// Resolve a config directory path, expanding `~` to the home directory.
/// Falls back to `default` if the config value is `None`.
fn resolve_dir(configured: Option<&str>, default: &str) -> PathBuf {
if let Some(dir) = configured {
let path = PathBuf::from(dir);
if let Ok(stripped) = path.strip_prefix("~") {
return dirs::home_dir().unwrap_or_default().join(stripped);
}
return path;
}
dirs::home_dir().unwrap_or_default().join(default)
}
fn get_projects_dir(app: &'static App) -> PathBuf {
let configured = app
.config
.settings
.project
.as_ref()
.and_then(|p| p.directory.as_deref());
resolve_dir(configured, ".gitnow/projects")
}
fn get_templates_dir(app: &'static App) -> PathBuf {
let configured = app
.config
.settings
.project
.as_ref()
.and_then(|p| p.templates_directory.as_deref());
resolve_dir(configured, ".gitnow/templates")
}
/// List subdirectories of `dir` as `DirEntry` items.
/// Projects with metadata are sorted by creation time (most recent first),
/// followed by projects without metadata sorted alphabetically.
fn list_subdirectories(dir: &Path) -> anyhow::Result<Vec<DirEntry>> {
if !dir.exists() {
return Ok(Vec::new());
}
let mut entries = Vec::new();
for entry in std::fs::read_dir(dir)? {
let entry = entry?;
if entry.file_type()?.is_dir() {
let path = entry.path();
let metadata = ProjectMetadata::load(&path);
entries.push(DirEntry {
name: entry.file_name().to_string_lossy().to_string(),
path,
metadata,
});
}
}
entries.sort_by(|a, b| {
match (&a.metadata, &b.metadata) {
// Both have metadata: most recent first
(Some(a_meta), Some(b_meta)) => b_meta.created_at.cmp(&a_meta.created_at),
// Metadata projects come before non-metadata ones
(Some(_), None) => std::cmp::Ordering::Less,
(None, Some(_)) => std::cmp::Ordering::Greater,
// Both without metadata: alphabetical
(None, None) => a.name.cmp(&b.name),
}
});
Ok(entries)
}
fn copy_dir_recursive(src: &Path, dst: &Path) -> anyhow::Result<()> {
std::fs::create_dir_all(dst)?;
for entry in std::fs::read_dir(src)? {
let entry = entry?;
let dest_path = dst.join(entry.file_name());
if entry.file_type()?.is_dir() {
copy_dir_recursive(&entry.path(), &dest_path)?;
} else {
std::fs::copy(entry.path(), &dest_path)?;
}
}
Ok(())
}
/// Clone selected repositories concurrently into `target_dir`.
async fn clone_repos_into(
app: &'static App,
repos: &[crate::git_provider::Repository],
target_dir: &Path,
) -> anyhow::Result<()> {
let clone_template = app
.config
.settings
.clone_command
.as_deref()
.unwrap_or(template_command::DEFAULT_CLONE_COMMAND);
let concurrency_limit = Arc::new(tokio::sync::Semaphore::new(5));
let mut handles = Vec::new();
for repo in repos {
let repo = repo.clone();
let target_dir = target_dir.to_path_buf();
let clone_template = clone_template.to_string();
let concurrency = Arc::clone(&concurrency_limit);
let custom_command = app.custom_command();
let handle = tokio::spawn(async move {
let _permit = concurrency.acquire().await?;
let clone_path = target_dir.join(&repo.repo_name);
if clone_path.exists() {
eprintln!(" {} already exists, skipping", repo.repo_name);
return Ok::<(), anyhow::Error>(());
}
eprintln!(" cloning {}...", repo.to_rel_path().display());
let path_str = clone_path.display().to_string();
let context = HashMap::from([
("ssh_url", repo.ssh_url.as_str()),
("path", path_str.as_str()),
]);
let output = template_command::render_and_execute(&clone_template, context).await?;
if !output.status.success() {
let stderr = std::str::from_utf8(&output.stderr).unwrap_or_default();
anyhow::bail!("failed to clone {}: {}", repo.repo_name, stderr);
}
custom_command
.execute_post_clone_command(&clone_path)
.await?;
Ok(())
});
handles.push(handle);
}
let results = futures::future::join_all(handles).await;
for res in results {
match res {
Ok(Ok(())) => {}
Ok(Err(e)) => {
tracing::error!("clone error: {}", e);
eprintln!("error: {}", e);
}
Err(e) => {
tracing::error!("task error: {}", e);
eprintln!("error: {}", e);
}
}
}
Ok(())
}
/// Helper to select an existing project, either by name or interactively.
fn select_project(
app: &'static App,
name: Option<String>,
projects: &[DirEntry],
) -> anyhow::Result<DirEntry> {
match name {
Some(name) => projects
.iter()
.find(|p| p.name == name)
.ok_or_else(|| anyhow::anyhow!("project '{}' not found", name))
.cloned(),
None => app
.interactive()
.interactive_search_items(projects)?
.ok_or_else(|| anyhow::anyhow!("no project selected")),
}
}
// --- Command implementations ---
impl ProjectCommand {
pub async fn execute(&mut self, app: &'static App, chooser: &Chooser) -> anyhow::Result<()> {
match self.command.take() {
Some(ProjectSubcommand::Create(mut create)) => create.execute(app, chooser).await,
Some(ProjectSubcommand::Add(mut add)) => add.execute(app).await,
Some(ProjectSubcommand::Delete(mut delete)) => delete.execute(app).await,
None => self.open_existing(app, chooser).await,
}
}
async fn open_existing(&self, app: &'static App, chooser: &Chooser) -> anyhow::Result<()> {
let projects_dir = get_projects_dir(app);
let projects = list_subdirectories(&projects_dir)?;
if projects.is_empty() {
anyhow::bail!(
"no projects found in {}. Use 'gitnow project create' to create one.",
projects_dir.display()
);
}
let project = match &self.search {
Some(needle) => {
let matched = projects
.iter()
.find(|p| p.name.contains(needle.as_str()))
.or_else(|| {
projects.iter().find(|p| {
p.name
.to_lowercase()
.contains(&needle.to_lowercase())
})
})
.ok_or(anyhow::anyhow!(
"no project matching '{}' found",
needle
))?
.clone();
matched
}
None => app
.interactive()
.interactive_search_items(&projects)?
.ok_or(anyhow::anyhow!("no project selected"))?,
};
if !self.no_shell && !chooser.is_active() {
app.shell().spawn_shell_at(&project.path).await?;
} else {
chooser.set(&project.path)?;
}
Ok(())
}
}
impl ProjectCreateCommand {
async fn execute(&mut self, app: &'static App, chooser: &Chooser) -> anyhow::Result<()> {
let name = match self.name.take() {
Some(n) => n,
None => {
eprint!("Project name: ");
let mut input = String::new();
std::io::stdin().read_line(&mut input)?;
let trimmed = input.trim().to_string();
if trimmed.is_empty() {
anyhow::bail!("project name cannot be empty");
}
trimmed
}
};
let dir_name = name
.replace(' ', "-")
.replace('/', "-")
.to_lowercase();
let projects_dir = get_projects_dir(app);
let project_path = projects_dir.join(&dir_name);
if project_path.exists() {
anyhow::bail!(
"project '{}' already exists at {}",
dir_name,
project_path.display()
);
}
let repositories = load_repositories(app, !self.no_cache).await?;
eprintln!("Select repositories (Tab to toggle, Enter to confirm):");
let selected_repos = app
.interactive()
.interactive_multi_search(&repositories)?;
if selected_repos.is_empty() {
anyhow::bail!("no repositories selected");
}
tokio::fs::create_dir_all(&project_path).await?;
clone_repos_into(app, &selected_repos, &project_path).await?;
// Apply template if requested
let templates_dir = get_templates_dir(app);
let template = match self.template.take() {
Some(name) => {
let templates = list_subdirectories(&templates_dir)?;
Some(
templates
.into_iter()
.find(|t| t.name == name)
.ok_or_else(|| {
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):");
app.interactive().interactive_search_items(&templates)?
} else {
None
}
}
};
let template_name = if let Some(template) = template {
eprintln!(" applying template '{}'...", template.name);
copy_dir_recursive(&template.path, &project_path)?;
Some(template.name.clone())
} else {
None
};
let repo_entries: Vec<RepoEntry> = selected_repos.iter().map(RepoEntry::from).collect();
let metadata = ProjectMetadata::new(dir_name.clone(), template_name, repo_entries);
metadata.save(&project_path)?;
eprintln!(
"project '{}' created at {} with {} repositories",
dir_name,
project_path.display(),
selected_repos.len()
);
if !self.no_shell && !chooser.is_active() {
app.shell().spawn_shell_at(&project_path).await?;
} else {
chooser.set(&project_path)?;
}
Ok(())
}
}
impl ProjectAddCommand {
async fn execute(&mut self, app: &'static App) -> anyhow::Result<()> {
let projects_dir = get_projects_dir(app);
let projects = list_subdirectories(&projects_dir)?;
if projects.is_empty() {
anyhow::bail!(
"no projects found in {}. Use 'gitnow project create' to create one.",
projects_dir.display()
);
}
let project = select_project(app, self.name.take(), &projects)?;
let repositories = load_repositories(app, !self.no_cache).await?;
eprintln!("Select repositories to add (Tab to toggle, Enter to confirm):");
let selected_repos = app
.interactive()
.interactive_multi_search(&repositories)?;
if selected_repos.is_empty() {
anyhow::bail!("no repositories selected");
}
clone_repos_into(app, &selected_repos, &project.path).await?;
if let Some(mut metadata) = ProjectMetadata::load(&project.path) {
let new_entries: Vec<RepoEntry> = selected_repos.iter().map(RepoEntry::from).collect();
metadata.add_repositories(new_entries);
metadata.save(&project.path)?;
}
eprintln!(
"added {} repositories to project '{}'",
selected_repos.len(),
project.name
);
Ok(())
}
}
impl ProjectDeleteCommand {
async fn execute(&mut self, app: &'static App) -> anyhow::Result<()> {
let projects_dir = get_projects_dir(app);
let projects = list_subdirectories(&projects_dir)?;
if projects.is_empty() {
anyhow::bail!("no projects found in {}", projects_dir.display());
}
let project = select_project(app, self.name.take(), &projects)?;
if !self.force {
eprint!(
"Delete project '{}' at {}? [y/N] ",
project.name,
project.path.display()
);
let mut input = String::new();
std::io::stdin().read_line(&mut input)?;
if !input.trim().eq_ignore_ascii_case("y") {
eprintln!("aborted");
return Ok(());
}
}
tokio::fs::remove_dir_all(&project.path).await?;
eprintln!("deleted project '{}'", project.name);
Ok(())
}
}

View File

@@ -2,7 +2,8 @@ use std::{collections::BTreeMap, io::IsTerminal};
use crate::{ use crate::{
app::App, app::App,
cache::CacheApp, cache::{load_repositories, CacheApp},
chooser::Chooser,
components::inline_command::InlineCommand, components::inline_command::InlineCommand,
custom_command::CustomCommandApp, custom_command::CustomCommandApp,
fuzzy_matcher::{FuzzyMatcher, FuzzyMatcherApp}, fuzzy_matcher::{FuzzyMatcher, FuzzyMatcherApp},
@@ -31,32 +32,17 @@ impl RootCommand {
shell: bool, shell: bool,
force_refresh: bool, force_refresh: bool,
force_cache_update: bool, force_cache_update: bool,
chooser: &Chooser,
) -> anyhow::Result<()> { ) -> anyhow::Result<()> {
tracing::debug!("executing"); tracing::debug!("executing");
let repositories = if !force_cache_update { let repositories = if force_cache_update {
if cache {
match self.app.cache().get().await? {
Some(repos) => repos,
None => {
tracing::info!("finding repositories...");
let repositories = self.app.projects_list().get_projects().await?;
self.app.cache().update(&repositories).await?;
repositories
}
}
} else {
self.app.projects_list().get_projects().await?
}
} else {
tracing::info!("forcing cache update..."); tracing::info!("forcing cache update...");
let repositories = self.app.projects_list().get_projects().await?; let repositories = self.app.projects_list().get_projects().await?;
self.app.cache().update(&repositories).await?; self.app.cache().update(&repositories).await?;
repositories repositories
} else {
load_repositories(self.app, cache).await?
}; };
let repo = match search { let repo = match search {
@@ -133,58 +119,28 @@ impl RootCommand {
self.app.shell().spawn_shell(&repo).await?; self.app.shell().spawn_shell(&repo).await?;
} else { } else {
tracing::info!("skipping shell for repo: {}", &repo.to_rel_path().display()); tracing::info!("skipping shell for repo: {}", &repo.to_rel_path().display());
println!( chooser.set(&self.app.config.settings.projects.directory.join(repo.to_rel_path()))?;
"{}",
self.app
.config
.settings
.projects
.directory
.join(repo.to_rel_path())
.display()
);
} }
Ok(()) Ok(())
} }
} }
trait StringExt {
fn as_str_vec(&self) -> Vec<&str>;
}
impl StringExt for Vec<String> {
fn as_str_vec(&self) -> Vec<&str> {
self.iter().map(|r| r.as_ref()).collect()
}
}
impl StringExt for Vec<&String> {
fn as_str_vec(&self) -> Vec<&str> {
self.iter().map(|r| r.as_ref()).collect()
}
}
pub trait RepositoryMatcher { pub trait RepositoryMatcher {
fn match_repositories(&self, pattern: &str, repositories: &[Repository]) -> Vec<Repository>; fn match_repositories(&self, pattern: &str, repositories: &[Repository]) -> Vec<Repository>;
} }
impl RepositoryMatcher for FuzzyMatcher { impl RepositoryMatcher for FuzzyMatcher {
fn match_repositories(&self, pattern: &str, repositories: &[Repository]) -> Vec<Repository> { fn match_repositories(&self, pattern: &str, repositories: &[Repository]) -> Vec<Repository> {
let haystack = repositories let haystack: BTreeMap<String, &Repository> = repositories
.iter() .iter()
.map(|r| (r.to_rel_path().display().to_string(), r)) .map(|r| (r.to_rel_path().display().to_string(), r))
.collect::<BTreeMap<_, _>>(); .collect();
let haystack_keys = haystack.keys().collect::<Vec<_>>(); let keys: Vec<&str> = haystack.keys().map(|s| s.as_str()).collect();
let haystack_keys = haystack_keys.as_str_vec();
let res = self.match_pattern(pattern, &haystack_keys); self.match_pattern(pattern, &keys)
let matched_repos = res
.into_iter() .into_iter()
.filter_map(|repo_key| haystack.get(repo_key).map(|r| (*r).to_owned())) .filter_map(|key| haystack.get(key).map(|r| (*r).to_owned()))
.collect::<Vec<_>>(); .collect()
matched_repos
} }
} }

View File

@@ -2,11 +2,11 @@ use std::io::IsTerminal;
use crate::{ use crate::{
app::App, app::App,
cache::CacheApp, cache::load_repositories,
chooser::Chooser,
components::inline_command::InlineCommand, components::inline_command::InlineCommand,
fuzzy_matcher::FuzzyMatcherApp, fuzzy_matcher::FuzzyMatcherApp,
interactive::{InteractiveApp, StringItem}, interactive::{InteractiveApp, StringItem},
projects_list::ProjectsListApp,
shell::ShellApp, shell::ShellApp,
worktree::{sanitize_branch_name, WorktreeApp}, worktree::{sanitize_branch_name, WorktreeApp},
}; };
@@ -33,21 +33,9 @@ pub struct WorktreeCommand {
} }
impl WorktreeCommand { impl WorktreeCommand {
pub async fn execute(&mut self, app: &'static App) -> anyhow::Result<()> { pub async fn execute(&mut self, app: &'static App, chooser: &Chooser) -> anyhow::Result<()> {
// Step 1: Load repositories // Step 1: Load repositories
let repositories = if !self.no_cache { let repositories = load_repositories(app, !self.no_cache).await?;
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 // Step 2: Select repository
let repo = match &self.search { let repo = match &self.search {
@@ -155,10 +143,10 @@ impl WorktreeCommand {
} }
// Step 7: Enter shell or print path // Step 7: Enter shell or print path
if !self.no_shell { if !self.no_shell && !chooser.is_active() {
app.shell().spawn_shell_at(&worktree_path).await?; app.shell().spawn_shell_at(&worktree_path).await?;
} else { } else {
println!("{}", worktree_path.display()); chooser.set(&worktree_path)?;
} }
Ok(()) Ok(())

View File

@@ -30,6 +30,22 @@ pub struct Settings {
/// Worktree configuration. /// Worktree configuration.
#[serde(default)] #[serde(default)]
pub worktree: Option<WorktreeSettings>, pub worktree: Option<WorktreeSettings>,
/// Project scratch-pad configuration.
#[serde(default)]
pub project: Option<ProjectSettings>,
}
#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
pub struct ProjectSettings {
/// Directory where projects are stored.
/// Default: "~/.gitnow/projects"
pub directory: Option<String>,
/// Directory containing project templates.
/// Each subdirectory is a template whose files are copied into new projects.
/// Default: "~/.gitnow/templates"
pub templates_directory: Option<String>,
} }
#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)] #[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
@@ -47,37 +63,26 @@ pub struct WorktreeSettings {
pub list_branches_command: Option<String>, pub list_branches_command: Option<String>,
} }
/// A list of shell commands that can be specified as a single string or an array in TOML.
#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)] #[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
#[serde(untagged)] #[serde(untagged)]
pub enum PostUpdateCommand { pub enum CommandList {
Single(String), Single(String),
Multiple(Vec<String>), Multiple(Vec<String>),
} }
impl PostUpdateCommand { impl CommandList {
pub fn get_commands(&self) -> Vec<String> { pub fn get_commands(&self) -> Vec<String> {
match self.clone() { match self.clone() {
PostUpdateCommand::Single(item) => vec![item], CommandList::Single(item) => vec![item],
PostUpdateCommand::Multiple(items) => items, CommandList::Multiple(items) => items,
} }
} }
} }
#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)] /// Backwards-compatible type aliases.
#[serde(untagged)] pub type PostCloneCommand = CommandList;
pub enum PostCloneCommand { pub type PostUpdateCommand = CommandList;
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)] #[derive(Debug, Default, Serialize, Deserialize, PartialEq, Clone)]
pub struct Projects { pub struct Projects {
@@ -213,35 +218,28 @@ pub struct GitHub {
pub organisations: Vec<GitHubOrganisation>, pub organisations: Vec<GitHubOrganisation>,
} }
#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)] /// Generates a newtype wrapper around `String` with `From` impls for owned and borrowed access.
pub struct GitHubUser(String); macro_rules! string_newtype {
($name:ident) => {
#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
pub struct $name(String);
impl From<GitHubUser> for String { impl From<$name> for String {
fn from(value: GitHubUser) -> Self { fn from(value: $name) -> Self {
value.0 value.0
} }
}
impl<'a> From<&'a $name> for &'a str {
fn from(value: &'a $name) -> Self {
value.0.as_str()
}
}
};
} }
impl<'a> From<&'a GitHubUser> for &'a str { string_newtype!(GitHubUser);
fn from(value: &'a GitHubUser) -> Self { string_newtype!(GitHubOrganisation);
value.0.as_str()
}
}
#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
pub struct GitHubOrganisation(String);
impl From<GitHubOrganisation> for String {
fn from(value: GitHubOrganisation) -> Self {
value.0
}
}
impl<'a> From<&'a GitHubOrganisation> for &'a str {
fn from(value: &'a GitHubOrganisation) -> Self {
value.0.as_str()
}
}
#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)] #[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
pub struct Gitea { pub struct Gitea {
@@ -273,34 +271,8 @@ pub enum GitHubAccessToken {
Env { env: String }, Env { env: String },
} }
#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)] string_newtype!(GiteaUser);
pub struct GiteaUser(String); string_newtype!(GiteaOrganisation);
impl From<GiteaUser> for String {
fn from(value: GiteaUser) -> Self {
value.0
}
}
impl<'a> From<&'a GiteaUser> for &'a str {
fn from(value: &'a GiteaUser) -> Self {
value.0.as_str()
}
}
#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
pub struct GiteaOrganisation(String);
impl From<GiteaOrganisation> for String {
fn from(value: GiteaOrganisation) -> Self {
value.0
}
}
impl<'a> From<&'a GiteaOrganisation> for &'a str {
fn from(value: &'a GiteaOrganisation) -> Self {
value.0.as_str()
}
}
impl Config { impl Config {
pub async fn from_file(file_path: &Path) -> anyhow::Result<Config> { pub async fn from_file(file_path: &Path) -> anyhow::Result<Config> {
@@ -424,6 +396,7 @@ mod test {
post_clone_command: None, post_clone_command: None,
clone_command: None, clone_command: None,
worktree: None, worktree: None,
project: None,
} }
}, },
config config
@@ -453,6 +426,7 @@ mod test {
post_clone_command: None, post_clone_command: None,
clone_command: None, clone_command: None,
worktree: None, worktree: None,
project: None,
} }
}, },
config config

View File

@@ -1,73 +1,65 @@
use std::path::Path; use std::path::Path;
use crate::{app::App, config::Config}; use crate::{app::App, config::CommandList};
pub struct CustomCommand { pub struct CustomCommand {
config: Config, post_clone: Option<CommandList>,
post_update: Option<CommandList>,
} }
impl CustomCommand { impl CustomCommand {
pub fn new(config: Config) -> Self { pub fn new(app: &App) -> Self {
Self { config } Self {
post_clone: app.config.settings.post_clone_command.clone(),
post_update: app.config.settings.post_update_command.clone(),
}
} }
pub async fn execute_post_clone_command(&self, path: &Path) -> anyhow::Result<()> { async fn execute_commands(
if let Some(post_clone_command) = &self.config.settings.post_clone_command { commands: &CommandList,
for command in post_clone_command.get_commands() { path: &Path,
let command_parts = command.split(' ').collect::<Vec<_>>(); label: &str,
let Some((first, rest)) = command_parts.split_first() else { ) -> anyhow::Result<()> {
return Ok(()); for command in commands.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); let mut cmd = tokio::process::Command::new(first);
cmd.args(rest).current_dir(path); cmd.args(rest).current_dir(path);
eprintln!("running command: {}", command); eprintln!("running command: {}", command);
tracing::info!( tracing::info!(
path = path.display().to_string(), path = path.display().to_string(),
cmd = command, cmd = command,
"running custom post clone command" "running custom {} command",
); label
let output = cmd.output().await?; );
let stdout = std::str::from_utf8(&output.stdout)?; let output = cmd.output().await?;
tracing::info!( let stdout = std::str::from_utf8(&output.stdout)?;
stdout = stdout, tracing::info!(
"finished running custom post clone command" stdout = stdout,
); "finished running custom {} command",
} label
);
} }
Ok(()) Ok(())
} }
pub async fn execute_post_update_command(&self, path: &Path) -> anyhow::Result<()> { pub async fn execute_post_clone_command(&self, path: &Path) -> anyhow::Result<()> {
if let Some(post_update_command) = &self.config.settings.post_update_command { if let Some(ref cmds) = self.post_clone {
for command in post_update_command.get_commands() { Self::execute_commands(cmds, path, "post clone").await?;
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 async fn execute_post_update_command(&self, path: &Path) -> anyhow::Result<()> {
if let Some(ref cmds) = self.post_update {
Self::execute_commands(cmds, path, "post update").await?;
}
Ok(()) Ok(())
} }
} }
@@ -78,6 +70,6 @@ pub trait CustomCommandApp {
impl CustomCommandApp for App { impl CustomCommandApp for App {
fn custom_command(&self) -> CustomCommand { fn custom_command(&self) -> CustomCommand {
CustomCommand::new(self.config.clone()) CustomCommand::new(self)
} }
} }

View File

@@ -1,18 +1,34 @@
use anyhow::Context; use anyhow::Context;
use gitea_client::apis::configuration::Configuration; use serde::Deserialize;
use url::Url; use url::Url;
use crate::{app::App, config::GiteaAccessToken}; use crate::{app::App, config::GiteaAccessToken};
#[derive(Debug, Deserialize)]
struct GiteaRepo {
name: Option<String>,
ssh_url: Option<String>,
owner: Option<GiteaUser>,
}
#[derive(Debug, Deserialize)]
struct GiteaUser {
login: Option<String>,
}
#[derive(Debug)] #[derive(Debug)]
pub struct GiteaProvider { pub struct GiteaProvider {
#[allow(dead_code)] #[allow(dead_code)]
app: &'static App, app: &'static App,
client: reqwest::Client,
} }
impl GiteaProvider { impl GiteaProvider {
pub fn new(app: &'static App) -> GiteaProvider { pub fn new(app: &'static App) -> GiteaProvider {
GiteaProvider { app } GiteaProvider {
app,
client: reqwest::Client::new(),
}
} }
pub async fn list_repositories_for_current_user( pub async fn list_repositories_for_current_user(
@@ -22,39 +38,23 @@ impl GiteaProvider {
) -> anyhow::Result<Vec<super::Repository>> { ) -> anyhow::Result<Vec<super::Repository>> {
tracing::debug!("fetching gitea repositories for current user"); tracing::debug!("fetching gitea repositories for current user");
let config = self.get_config(api, access_token)?;
let mut repositories = Vec::new(); let mut repositories = Vec::new();
let mut page = 1; let mut page = 1;
loop { loop {
let mut repos = self let repos: Vec<GiteaRepo> = self
.list_repositories_for_current_user_with_page(&config, page) .request(&format!("{api}/user/repos"), access_token, page)
.await?; .await?;
if repos.is_empty() { if repos.is_empty() {
break; break;
} }
repositories.append(&mut repos); repositories.extend(repos);
page += 1; page += 1;
} }
let provider = &Self::get_domain(api)?; let provider = &Self::get_domain(api)?;
Ok(to_repositories(provider, repositories))
Ok(repositories
.into_iter()
.map(|repo| super::Repository {
provider: provider.into(),
owner: repo
.owner
.map(|user| user.login.unwrap_or_default())
.unwrap_or_default(),
repo_name: repo.name.unwrap_or_default(),
ssh_url: repo
.ssh_url
.expect("ssh url to be set for a gitea repository"),
})
.collect())
} }
fn get_domain(api: &str) -> anyhow::Result<String> { fn get_domain(api: &str) -> anyhow::Result<String> {
@@ -64,19 +64,6 @@ impl GiteaProvider {
Ok(provider.into()) Ok(provider.into())
} }
async fn list_repositories_for_current_user_with_page(
&self,
config: &Configuration,
page: usize,
) -> anyhow::Result<Vec<gitea_client::models::Repository>> {
let repos =
gitea_client::apis::user_api::user_current_list_repos(config, Some(page as i32), None)
.await
.context("failed to fetch repos for users")?;
Ok(repos)
}
pub async fn list_repositories_for_user( pub async fn list_repositories_for_user(
&self, &self,
user: &str, user: &str,
@@ -85,53 +72,27 @@ impl GiteaProvider {
) -> anyhow::Result<Vec<super::Repository>> { ) -> anyhow::Result<Vec<super::Repository>> {
tracing::debug!(user = user, "fetching gitea repositories for user"); tracing::debug!(user = user, "fetching gitea repositories for user");
let config = self.get_config(api, access_token)?;
let mut repositories = Vec::new(); let mut repositories = Vec::new();
let mut page = 1; let mut page = 1;
loop { loop {
let mut repos = self let repos: Vec<GiteaRepo> = self
.list_repositories_for_user_with_page(user, &config, page) .request(
&format!("{api}/users/{user}/repos"),
access_token,
page,
)
.await?; .await?;
if repos.is_empty() { if repos.is_empty() {
break; break;
} }
repositories.append(&mut repos); repositories.extend(repos);
page += 1; page += 1;
} }
let provider = &Self::get_domain(api)?; let provider = &Self::get_domain(api)?;
Ok(to_repositories(provider, repositories))
Ok(repositories
.into_iter()
.map(|repo| super::Repository {
provider: provider.into(),
owner: repo
.owner
.map(|user| user.login.unwrap_or_default())
.unwrap_or_default(),
repo_name: repo.name.unwrap_or_default(),
ssh_url: repo
.ssh_url
.expect("ssh url to be set for gitea repository"),
})
.collect())
}
pub async fn list_repositories_for_user_with_page(
&self,
user: &str,
config: &Configuration,
page: usize,
) -> anyhow::Result<Vec<gitea_client::models::Repository>> {
let repos =
gitea_client::apis::user_api::user_list_repos(config, user, Some(page as i32), None)
.await
.context("failed to fetch repos for users")?;
Ok(repos)
} }
pub async fn list_repositories_for_organisation( pub async fn list_repositories_for_organisation(
@@ -144,83 +105,78 @@ impl GiteaProvider {
organisation = organisation, organisation = organisation,
"fetching gitea repositories for organisation" "fetching gitea repositories for organisation"
); );
let config = self.get_config(api, access_token)?;
let mut repositories = Vec::new(); let mut repositories = Vec::new();
let mut page = 1; let mut page = 1;
loop { loop {
let mut repos = self let repos: Vec<GiteaRepo> = self
.list_repositories_for_organisation_with_page(organisation, &config, page) .request(
&format!("{api}/orgs/{organisation}/repos"),
access_token,
page,
)
.await?; .await?;
if repos.is_empty() { if repos.is_empty() {
break; break;
} }
repositories.append(&mut repos); repositories.extend(repos);
page += 1; page += 1;
} }
let provider = &Self::get_domain(api)?; let provider = &Self::get_domain(api)?;
Ok(to_repositories(provider, repositories))
Ok(repositories
.into_iter()
.map(|repo| super::Repository {
provider: provider.into(),
owner: repo
.owner
.map(|user| user.login.unwrap_or_default())
.unwrap_or_default(),
repo_name: repo.name.unwrap_or_default(),
ssh_url: repo
.ssh_url
.expect("ssh url to be set for gitea repository"),
})
.collect())
} }
pub async fn list_repositories_for_organisation_with_page( async fn request<T: serde::de::DeserializeOwned>(
&self, &self,
organisation: &str, url: &str,
config: &Configuration,
page: usize,
) -> anyhow::Result<Vec<gitea_client::models::Repository>> {
let repos = gitea_client::apis::organization_api::org_list_repos(
config,
organisation,
Some(page as i32),
None,
)
.await
.context("failed to fetch repos for users")?;
Ok(repos)
}
fn get_config(
&self,
api: &str,
access_token: Option<&GiteaAccessToken>, access_token: Option<&GiteaAccessToken>,
) -> anyhow::Result<Configuration> { page: usize,
let mut config = gitea_client::apis::configuration::Configuration::new(); ) -> anyhow::Result<T> {
config.base_path = api.into(); let mut req = self.client.get(url).query(&[("page", page.to_string())]);
match access_token { match access_token {
Some(GiteaAccessToken::Env { env }) => { Some(GiteaAccessToken::Env { env }) => {
let token = let token =
std::env::var(env).context(format!("{env} didn't have a valid value"))?; std::env::var(env).context(format!("{env} didn't have a valid value"))?;
req = req.basic_auth("", Some(token));
config.basic_auth = Some(("".into(), Some(token)));
} }
Some(GiteaAccessToken::Direct(var)) => { Some(GiteaAccessToken::Direct(var)) => {
config.bearer_access_token = Some(var.to_owned()); req = req.bearer_auth(var);
} }
None => {} None => {}
} }
Ok(config) req.send()
.await
.context("failed to send request")?
.error_for_status()
.context("request failed")?
.json()
.await
.context("failed to parse response")
} }
} }
fn to_repositories(provider: &str, repos: Vec<GiteaRepo>) -> Vec<super::Repository> {
repos
.into_iter()
.map(|repo| super::Repository {
provider: provider.into(),
owner: repo
.owner
.map(|user| user.login.unwrap_or_default())
.unwrap_or_default(),
repo_name: repo.name.unwrap_or_default(),
ssh_url: repo
.ssh_url
.expect("ssh url to be set for a gitea repository"),
})
.collect()
}
pub trait GiteaProviderApp { pub trait GiteaProviderApp {
fn gitea_provider(&self) -> GiteaProvider; fn gitea_provider(&self) -> GiteaProvider;
} }

View File

@@ -1,7 +1,20 @@
use octocrab::{models::Repository, params::repos::Sort, Octocrab, Page}; use reqwest::header::{ACCEPT, AUTHORIZATION, USER_AGENT};
use serde::Deserialize;
use crate::{app::App, config::GitHubAccessToken}; use crate::{app::App, config::GitHubAccessToken};
#[derive(Deserialize)]
struct GitHubRepo {
name: String,
owner: Option<GitHubOwner>,
ssh_url: Option<String>,
}
#[derive(Deserialize)]
struct GitHubOwner {
login: String,
}
pub struct GitHubProvider { pub struct GitHubProvider {
#[allow(dead_code)] #[allow(dead_code)]
app: &'static App, app: &'static App,
@@ -19,30 +32,17 @@ impl GitHubProvider {
) -> anyhow::Result<Vec<super::Repository>> { ) -> anyhow::Result<Vec<super::Repository>> {
tracing::debug!("fetching github repositories for current user"); tracing::debug!("fetching github repositories for current user");
let client = self.get_client(url, access_token)?; let client = self.get_client(access_token)?;
let base = self.api_base(url);
let current_page = client let repos: Vec<GitHubRepo> = self
.current() .paginate(
.list_repos_for_authenticated_user() &client,
.type_("all") &format!("{base}/user/repos?type=all&sort=full_name&per_page=100"),
.per_page(100) )
.sort("full_name")
.send()
.await?; .await?;
let repos = self.unfold_pages(client, current_page).await?; Ok(self.to_repositories(url, repos))
Ok(repos
.into_iter()
.filter_map(|repo| {
Some(super::Repository {
provider: self.get_url(url),
owner: repo.owner.map(|su| su.login)?,
repo_name: repo.name,
ssh_url: repo.ssh_url?,
})
})
.collect())
} }
pub async fn list_repositories_for_user( pub async fn list_repositories_for_user(
@@ -53,30 +53,17 @@ impl GitHubProvider {
) -> anyhow::Result<Vec<super::Repository>> { ) -> anyhow::Result<Vec<super::Repository>> {
tracing::debug!(user = user, "fetching github repositories for user"); tracing::debug!(user = user, "fetching github repositories for user");
let client = self.get_client(url, access_token)?; let client = self.get_client(access_token)?;
let base = self.api_base(url);
let current_page = client let repos: Vec<GitHubRepo> = self
.users(user) .paginate(
.repos() &client,
.r#type(octocrab::params::users::repos::Type::All) &format!("{base}/users/{user}/repos?type=all&sort=full_name&per_page=100"),
.sort(Sort::FullName) )
.per_page(100)
.send()
.await?; .await?;
let repos = self.unfold_pages(client, current_page).await?; Ok(self.to_repositories(url, repos))
Ok(repos
.into_iter()
.filter_map(|repo| {
Some(super::Repository {
provider: self.get_url(url),
owner: repo.owner.map(|su| su.login)?,
repo_name: repo.name,
ssh_url: repo.ssh_url?,
})
})
.collect())
} }
pub async fn list_repositories_for_organisation( pub async fn list_repositories_for_organisation(
@@ -90,47 +77,66 @@ impl GitHubProvider {
"fetching github repositories for organisation" "fetching github repositories for organisation"
); );
let client = self.get_client(url, access_token)?; let client = self.get_client(access_token)?;
let base = self.api_base(url);
let current_page = client let repos: Vec<GitHubRepo> = self
.orgs(organisation) .paginate(
.list_repos() &client,
.repo_type(Some(octocrab::params::repos::Type::All)) &format!("{base}/orgs/{organisation}/repos?type=all&sort=full_name&per_page=100"),
.sort(Sort::FullName) )
.per_page(100)
.send()
.await?; .await?;
let repos = self.unfold_pages(client, current_page).await?; Ok(self.to_repositories(url, repos))
}
Ok(repos async fn paginate(
&self,
client: &reqwest::Client,
initial_url: &str,
) -> anyhow::Result<Vec<GitHubRepo>> {
let mut repos = Vec::new();
let mut url = Some(initial_url.to_string());
while let Some(current_url) = url {
let resp = client
.get(&current_url)
.send()
.await?
.error_for_status()?;
url = parse_next_link(resp.headers());
let page: Vec<GitHubRepo> = resp.json().await?;
repos.extend(page);
}
Ok(repos)
}
fn to_repositories(
&self,
url: Option<&String>,
repos: Vec<GitHubRepo>,
) -> Vec<super::Repository> {
repos
.into_iter() .into_iter()
.filter_map(|repo| { .filter_map(|repo| {
Some(super::Repository { Some(super::Repository {
provider: self.get_url(url), provider: self.get_url(url),
owner: repo.owner.map(|su| su.login)?, owner: repo.owner.map(|o| o.login)?,
repo_name: repo.name, repo_name: repo.name,
ssh_url: repo.ssh_url?, ssh_url: repo.ssh_url?,
}) })
}) })
.collect()) .collect()
} }
async fn unfold_pages( fn api_base(&self, url: Option<&String>) -> String {
&self, match url {
client: octocrab::Octocrab, Some(u) => format!("{u}/api/v3"),
page: Page<Repository>, None => "https://api.github.com".to_string(),
) -> anyhow::Result<Vec<Repository>> {
let mut current_page = page;
let mut repos = current_page.take_items();
while let Ok(Some(mut new_page)) = client.get_page(&current_page.next).await {
repos.extend(new_page.take_items());
current_page = new_page;
} }
Ok(repos)
} }
fn get_url(&self, url: Option<&String>) -> String { fn get_url(&self, url: Option<&String>) -> String {
@@ -151,15 +157,19 @@ impl GitHubProvider {
} }
} }
fn get_client( fn get_client(&self, access_token: &GitHubAccessToken) -> anyhow::Result<reqwest::Client> {
&self, let token = match access_token {
_url: Option<&String>, GitHubAccessToken::Direct(token) => token.to_owned(),
access_token: &GitHubAccessToken, GitHubAccessToken::Env { env } => std::env::var(env)?,
) -> anyhow::Result<Octocrab> { };
let client = octocrab::Octocrab::builder()
.personal_token(match access_token { let client = reqwest::Client::builder()
GitHubAccessToken::Direct(token) => token.to_owned(), .default_headers({
GitHubAccessToken::Env { env } => std::env::var(env)?, let mut headers = reqwest::header::HeaderMap::new();
headers.insert(AUTHORIZATION, format!("token {token}").parse()?);
headers.insert(ACCEPT, "application/vnd.github+json".parse()?);
headers.insert(USER_AGENT, "gitnow".parse()?);
headers
}) })
.build()?; .build()?;
@@ -167,6 +177,18 @@ impl GitHubProvider {
} }
} }
fn parse_next_link(headers: &reqwest::header::HeaderMap) -> Option<String> {
let link = headers.get("link")?.to_str().ok()?;
for part in link.split(',') {
let part = part.trim();
if part.ends_with("rel=\"next\"") {
let url = part.split('>').next()?.trim_start_matches('<');
return Some(url.to_string());
}
}
None
}
pub trait GitHubProviderApp { pub trait GitHubProviderApp {
fn github_provider(&self) -> GitHubProvider; fn github_provider(&self) -> GitHubProvider;
} }

View File

@@ -47,6 +47,16 @@ impl Interactive {
App::new(self.app, items).run(terminal) App::new(self.app, items).run(terminal)
} }
pub fn interactive_multi_search<T: Searchable>(
&mut self,
items: &[T],
) -> anyhow::Result<Vec<T>> {
let backend = TermwizBackend::new().map_err(|e| anyhow::anyhow!(e.to_string()))?;
let terminal = Terminal::new(backend)?;
multi_select::MultiSelectApp::new(self.app, items).run(terminal)
}
} }
pub trait InteractiveApp { pub trait InteractiveApp {
@@ -197,3 +207,180 @@ mod app {
} }
} }
} }
pub mod multi_select {
use std::collections::HashSet;
use crossterm::event::KeyModifiers;
use ratatui::{
crossterm::event::{self, Event, KeyCode},
layout::{Constraint, Layout},
prelude::TermwizBackend,
style::{Style, Stylize},
text::{Line, Span},
widgets::{ListItem, ListState, Paragraph, StatefulWidget},
Frame, Terminal,
};
use crate::fuzzy_matcher::FuzzyMatcherApp;
use super::Searchable;
pub struct MultiSelectApp<'a, T: Searchable> {
app: &'static crate::app::App,
items: &'a [T],
current_search: String,
matched_items: Vec<T>,
selected_labels: HashSet<String>,
list: ListState,
}
impl<'a, T: Searchable> MultiSelectApp<'a, T> {
pub fn new(app: &'static crate::app::App, items: &'a [T]) -> Self {
Self {
app,
items,
current_search: String::default(),
matched_items: Vec::default(),
selected_labels: HashSet::new(),
list: ListState::default(),
}
}
fn update_matched_items(&mut self) {
let labels: Vec<String> = self.items.iter().map(|i| i.display_label()).collect();
let label_refs: Vec<&str> = labels.iter().map(|s| s.as_str()).collect();
let matched_keys = self
.app
.fuzzy_matcher()
.match_pattern(&self.current_search, &label_refs);
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();
}
}
fn toggle_current(&mut self) {
if let Some(selected) = self.list.selected() {
if let Some(item) = self.matched_items.get(selected) {
let label = item.display_label();
if !self.selected_labels.remove(&label) {
self.selected_labels.insert(label);
}
}
}
}
pub fn run(
mut self,
mut terminal: Terminal<TermwizBackend>,
) -> anyhow::Result<Vec<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
&& key.modifiers.contains(KeyModifiers::CONTROL)
{
return Ok(Vec::new());
}
match key.code {
KeyCode::Tab => {
self.toggle_current();
}
KeyCode::Char(letter) => {
self.current_search.push(letter);
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_items();
}
}
KeyCode::Esc => {
return Ok(Vec::new());
}
KeyCode::Enter => {
terminal.resize(ratatui::layout::Rect::ZERO)?;
let selected: Vec<T> = self
.items
.iter()
.filter(|i| self.selected_labels.contains(&i.display_label()))
.cloned()
.collect();
return Ok(selected);
}
KeyCode::Up => self.list.select_next(),
KeyCode::Down => self.list.select_previous(),
_ => {}
}
}
}
}
fn draw(&mut self, frame: &mut Frame) {
let vertical = Layout::vertical([
Constraint::Percentage(100),
Constraint::Min(1),
Constraint::Min(1),
]);
let [list_area, input_area, hint_area] = vertical.areas(frame.area());
let list_items: Vec<ListItem> = self
.matched_items
.iter()
.map(|i| {
let label = i.display_label();
let marker = if self.selected_labels.contains(&label) {
"[x] "
} else {
"[ ] "
};
ListItem::from(Line::from(vec![
Span::from(marker).green(),
Span::from(label),
]))
})
.collect();
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(list, list_area, frame.buffer_mut(), &mut self.list);
let input = Paragraph::new(Line::from(vec![
Span::from("> ").blue(),
Span::from(self.current_search.as_str()),
Span::from(" ").on_white(),
]));
frame.render_widget(input, input_area);
let count = self.selected_labels.len();
let hint = Paragraph::new(Line::from(vec![
Span::from(format!("{count} selected")).dim(),
Span::from(" | Tab: toggle, Enter: confirm").dim(),
]));
frame.render_widget(hint, hint_area);
}
}
}

View File

@@ -3,7 +3,7 @@ use std::path::PathBuf;
use anyhow::Context; use anyhow::Context;
use clap::{Parser, Subcommand}; use clap::{Parser, Subcommand};
use commands::{ use commands::{
clone::CloneCommand, root::RootCommand, shell::Shell, update::Update, clone::CloneCommand, project::ProjectCommand, root::RootCommand, shell::Shell, update::Update,
worktree::WorktreeCommand, worktree::WorktreeCommand,
}; };
use config::Config; use config::Config;
@@ -13,6 +13,7 @@ use tracing_subscriber::EnvFilter;
mod app; mod app;
mod cache; mod cache;
mod cache_codec; mod cache_codec;
pub mod chooser;
mod commands; mod commands;
mod components; mod components;
mod config; mod config;
@@ -21,6 +22,7 @@ mod fuzzy_matcher;
mod git_clone; mod git_clone;
mod git_provider; mod git_provider;
mod interactive; mod interactive;
mod project_metadata;
mod projects_list; mod projects_list;
mod shell; mod shell;
mod template_command; mod template_command;
@@ -48,6 +50,12 @@ struct Command {
#[arg(long = "no-shell", default_value = "false")] #[arg(long = "no-shell", default_value = "false")]
no_shell: bool, no_shell: bool,
/// Path to a chooser file; if set, the selected directory path is written
/// to this file instead of spawning a shell or printing to stdout.
/// Can also be set via the GITNOW_CHOOSER_FILE environment variable.
#[arg(long = "chooser-file", global = true, env = "GITNOW_CHOOSER_FILE")]
chooser_file: Option<PathBuf>,
#[arg(long = "force-refresh", default_value = "false")] #[arg(long = "force-refresh", default_value = "false")]
force_refresh: bool, force_refresh: bool,
@@ -61,6 +69,8 @@ enum Commands {
Update(Update), Update(Update),
Clone(CloneCommand), Clone(CloneCommand),
Worktree(WorktreeCommand), Worktree(WorktreeCommand),
/// Manage scratch-pad projects with multiple repositories
Project(ProjectCommand),
} }
const DEFAULT_CONFIG_PATH: &str = ".config/gitnow/gitnow.toml"; const DEFAULT_CONFIG_PATH: &str = ".config/gitnow/gitnow.toml";
@@ -94,6 +104,16 @@ async fn main() -> anyhow::Result<()> {
let app = app::App::new_static(config).await?; let app = app::App::new_static(config).await?;
// When a chooser file is provided, it implies --no-shell behaviour:
// the selected path is written to the file and no interactive shell is
// spawned. The calling shell wrapper is responsible for reading the
// file and changing directory.
let chooser = cli
.chooser_file
.map(chooser::Chooser::new)
.unwrap_or_default();
let no_shell = cli.no_shell || chooser.is_active();
match cli.command { match cli.command {
Some(cmd) => match cmd { Some(cmd) => match cmd {
Commands::Init(mut shell) => { Commands::Init(mut shell) => {
@@ -106,7 +126,10 @@ async fn main() -> anyhow::Result<()> {
clone.execute(app).await?; clone.execute(app).await?;
} }
Commands::Worktree(mut wt) => { Commands::Worktree(mut wt) => {
wt.execute(app).await?; wt.execute(app, &chooser).await?;
}
Commands::Project(mut project) => {
project.execute(app, &chooser).await?;
} }
}, },
None => { None => {
@@ -115,9 +138,10 @@ async fn main() -> anyhow::Result<()> {
cli.search.as_ref(), cli.search.as_ref(),
!cli.no_cache, !cli.no_cache,
!cli.no_clone, !cli.no_clone,
!cli.no_shell, !no_shell,
cli.force_refresh, cli.force_refresh,
cli.force_cache_update, cli.force_cache_update,
&chooser,
) )
.await?; .await?;
} }

View File

@@ -0,0 +1,124 @@
use std::path::Path;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use crate::git_provider::Repository;
pub const METADATA_FILENAME: &str = ".gitnow.json";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProjectMetadata {
pub version: u32,
pub name: String,
pub created_at: DateTime<Utc>,
#[serde(skip_serializing_if = "Option::is_none")]
pub template: Option<String>,
pub repositories: Vec<RepoEntry>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct RepoEntry {
pub provider: String,
pub owner: String,
pub repo_name: String,
pub ssh_url: String,
}
impl From<&Repository> for RepoEntry {
fn from(repo: &Repository) -> Self {
Self {
provider: repo.provider.clone(),
owner: repo.owner.clone(),
repo_name: repo.repo_name.clone(),
ssh_url: repo.ssh_url.clone(),
}
}
}
impl ProjectMetadata {
pub fn new(
name: String,
template: Option<String>,
repositories: Vec<RepoEntry>,
) -> Self {
Self {
version: 1,
name,
created_at: Utc::now(),
template,
repositories,
}
}
pub fn load(project_dir: &Path) -> Option<Self> {
let path = project_dir.join(METADATA_FILENAME);
let content = std::fs::read_to_string(&path).ok()?;
let metadata: Self = serde_json::from_str(&content).ok()?;
Some(metadata)
}
pub fn save(&self, project_dir: &Path) -> anyhow::Result<()> {
let path = project_dir.join(METADATA_FILENAME);
let content = serde_json::to_string_pretty(self)?;
std::fs::write(path, content)?;
Ok(())
}
pub fn add_repositories(&mut self, repos: Vec<RepoEntry>) {
for repo in repos {
if !self.repositories.iter().any(|r| r.ssh_url == repo.ssh_url) {
self.repositories.push(repo);
}
}
}
pub fn created_ago(&self) -> String {
let duration = Utc::now().signed_duration_since(self.created_at);
let days = duration.num_days();
if days > 365 {
let years = days / 365;
return if years == 1 {
"1 year ago".into()
} else {
format!("{years} years ago")
};
}
if days > 30 {
let months = days / 30;
return if months == 1 {
"1 month ago".into()
} else {
format!("{months} months ago")
};
}
if days > 0 {
return if days == 1 {
"1 day ago".into()
} else {
format!("{days} days ago")
};
}
let hours = duration.num_hours();
if hours > 0 {
return if hours == 1 {
"1 hour ago".into()
} else {
format!("{hours} hours ago")
};
}
let minutes = duration.num_minutes();
if minutes > 0 {
return if minutes == 1 {
"1 minute ago".into()
} else {
format!("{minutes} minutes ago")
};
}
"just now".into()
}
}

View File

@@ -1,6 +1,27 @@
[tools] [tools]
rust = "1.93" rust = "1.93"
cargo = "latest" goreleaser = "latest"
zig = "latest"
"cargo:cargo-zigbuild" = "latest"
[tasks.install] [tasks.install]
run = "cargo install --path ./crates/gitnow" run = "cargo install --path ./crates/gitnow"
[tasks.test]
run = "cargo test --workspace"
description = "Run all workspace tests"
[tasks.release]
run = "goreleaser release --clean"
description = "Create a release using GoReleaser (requires GITEA_TOKEN and a git tag)"
[tasks.release-snapshot]
run = "goreleaser release --snapshot --clean"
description = "Build a local snapshot release without publishing"
[tasks.release-docker]
run = """
docker build -f Dockerfile.release -t gitnow-release .
docker run --rm gitnow-release release-snapshot
"""
description = "Test the release Docker image locally (snapshot mode)"

17
templates/default/SPEC.md Normal file
View File

@@ -0,0 +1,17 @@
# Project Spec
## Overview
<!-- Describe the purpose and goals of this project -->
## Repositories
<!-- List the repositories included and their roles -->
## Architecture
<!-- Describe how the repositories relate to each other -->
## Getting Started
<!-- Steps to get up and running after cloning -->