Compare commits
1 Commits
v0.3.7
...
d7a1736f91
| Author | SHA1 | Date | |
|---|---|---|---|
| d7a1736f91 |
@@ -1,31 +0,0 @@
|
|||||||
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
1
.gitignore
vendored
@@ -1,4 +1,3 @@
|
|||||||
target/
|
target/
|
||||||
.cuddle/
|
.cuddle/
|
||||||
.DS_Store
|
.DS_Store
|
||||||
dist/
|
|
||||||
|
|||||||
@@ -1,60 +0,0 @@
|
|||||||
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."
|
|
||||||
28
CHANGELOG.md
28
CHANGELOG.md
@@ -6,34 +6,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
## [0.3.5] - 2025-03-04
|
|
||||||
|
|
||||||
### Added
|
|
||||||
- allow clone all
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- *(deps)* update rust crate serde to v1.0.218
|
|
||||||
- *(deps)* update tokio-prost monorepo to v0.13.5
|
|
||||||
- *(deps)* update rust crate termwiz to 0.23.0
|
|
||||||
- *(deps)* update all dependencies
|
|
||||||
- *(deps)* update rust crate uuid to v1.13.0
|
|
||||||
- *(deps)* update all dependencies
|
|
||||||
- *(deps)* update rust crate async-trait to v0.1.86
|
|
||||||
- *(deps)* update rust crate uuid to v1.12.1
|
|
||||||
- *(deps)* update rust crate octocrab to 0.43.0
|
|
||||||
- *(deps)* update rust crate uuid to v1.12.0
|
|
||||||
- *(deps)* update rust crate dirs to v6
|
|
||||||
- *(deps)* update rust crate uuid to v1.11.1
|
|
||||||
|
|
||||||
### Other
|
|
||||||
- *(deps)* update all dependencies
|
|
||||||
- *(deps)* update all dependencies
|
|
||||||
- *(deps)* update rust crate clap to v4.5.29
|
|
||||||
- *(deps)* update rust crate clap to v4.5.27
|
|
||||||
- *(deps)* update rust crate clap to v4.5.26
|
|
||||||
- *(deps)* update rust crate tokio to v1.43.0
|
|
||||||
- update example
|
|
||||||
|
|
||||||
## [0.3.4] - 2025-01-08
|
## [0.3.4] - 2025-01-08
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
2137
Cargo.lock
generated
2137
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -3,7 +3,7 @@ members = ["crates/*"]
|
|||||||
resolver = "2"
|
resolver = "2"
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "0.3.5"
|
version = "0.3.4"
|
||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
|
|
||||||
|
|||||||
@@ -1,23 +0,0 @@
|
|||||||
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"]
|
|
||||||
142
README.md
142
README.md
@@ -1,7 +1,5 @@
|
|||||||
# Git Now
|
# Git Now
|
||||||
|
|
||||||
> https://gitnow.kjuulh.io/
|
|
||||||
|
|
||||||
Git Now is a utility for easily navigating git projects from common upstream providers. Search, Download, and Enter projects as quickly as you can type.
|
Git Now is a utility for easily navigating git projects from common upstream providers. Search, Download, and Enter projects as quickly as you can type.
|
||||||
|
|
||||||

|

|
||||||
@@ -38,143 +36,3 @@ With gitnow
|
|||||||
1. `git now`
|
1. `git now`
|
||||||
2. Enter parts of the project name and press enter
|
2. Enter parts of the project name and press enter
|
||||||
3. Your project is automatically downloaded if it doesn't exist in an opinionated path dir, and move you there.
|
3. Your project is automatically downloaded if it doesn't exist in an opinionated path dir, and move you there.
|
||||||
|
|
||||||
## Configuration
|
|
||||||
|
|
||||||
Configuration lives at `~/.config/gitnow/gitnow.toml` (override with `$GITNOW_CONFIG`).
|
|
||||||
|
|
||||||
### Custom clone command
|
|
||||||
|
|
||||||
By default gitnow uses `git clone`. You can override this with any command using a [minijinja](https://docs.rs/minijinja) template:
|
|
||||||
|
|
||||||
```toml
|
|
||||||
[settings]
|
|
||||||
# Use jj (Jujutsu) instead of git
|
|
||||||
clone_command = "jj git clone {{ ssh_url }} {{ path }}"
|
|
||||||
```
|
|
||||||
|
|
||||||
Available template variables: `ssh_url`, `path`.
|
|
||||||
|
|
||||||
### Worktrees
|
|
||||||
|
|
||||||
gitnow supports git worktrees (or jj workspaces) via the `worktree` subcommand. This uses bare repositories so each branch gets its own directory as a sibling:
|
|
||||||
|
|
||||||
```
|
|
||||||
~/git/github.com/owner/repo/
|
|
||||||
├── .bare/ # bare clone (git clone --bare)
|
|
||||||
├── main/ # worktree for main branch
|
|
||||||
├── feature-login/ # worktree for feature/login branch
|
|
||||||
└── fix-typo/ # worktree for fix/typo branch
|
|
||||||
```
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Interactive: pick repo, then pick branch
|
|
||||||
gitnow worktree
|
|
||||||
|
|
||||||
# Pre-filter repo
|
|
||||||
gitnow worktree myproject
|
|
||||||
|
|
||||||
# Specify branch directly
|
|
||||||
gitnow worktree myproject -b feature/login
|
|
||||||
|
|
||||||
# Print worktree path instead of entering a shell
|
|
||||||
gitnow worktree myproject -b main --no-shell
|
|
||||||
```
|
|
||||||
|
|
||||||
All worktree commands are configurable via minijinja templates:
|
|
||||||
|
|
||||||
```toml
|
|
||||||
[settings.worktree]
|
|
||||||
# Default: "git clone --bare {{ ssh_url }} {{ bare_path }}"
|
|
||||||
clone_command = "git clone --bare {{ ssh_url }} {{ bare_path }}"
|
|
||||||
|
|
||||||
# Default: "git -C {{ bare_path }} worktree add {{ worktree_path }} {{ branch }}"
|
|
||||||
add_command = "git -C {{ bare_path }} worktree add {{ worktree_path }} {{ branch }}"
|
|
||||||
|
|
||||||
# Default: "git -C {{ bare_path }} branch --format=%(refname:short)"
|
|
||||||
list_branches_command = "git -C {{ bare_path }} branch --format=%(refname:short)"
|
|
||||||
```
|
|
||||||
|
|
||||||
For jj, you might use:
|
|
||||||
|
|
||||||
```toml
|
|
||||||
[settings]
|
|
||||||
clone_command = "jj git clone {{ ssh_url }} {{ path }}"
|
|
||||||
|
|
||||||
[settings.worktree]
|
|
||||||
clone_command = "jj git clone {{ ssh_url }} {{ bare_path }}"
|
|
||||||
add_command = "jj -R {{ bare_path }} workspace add --name {{ branch }} {{ worktree_path }}"
|
|
||||||
list_branches_command = "jj -R {{ bare_path }} bookmark list -T 'name ++ \"\\n\"'"
|
|
||||||
```
|
|
||||||
|
|
||||||
Available template variables for worktree commands: `bare_path`, `worktree_path`, `branch`, `ssh_url`.
|
|
||||||
|
|
||||||
### 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
|
|
||||||
```
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "gitnow"
|
name = "gitnow"
|
||||||
description = "Git Now is a utility for easily navigating git projects from common upstream providers. Search, Download, and Enter projects as quickly as you can type."
|
description = "Git Now is a utility for easily navigating git projects from common upstream providers. Search, Download, and Enter projects as quickly as you can type."
|
||||||
edition = "2024"
|
edition = "2021"
|
||||||
readme = "../../README.md"
|
readme = "../../README.md"
|
||||||
repository = "https://github.com/kjuulh/gitnow"
|
repository = "https://github.com/kjuulh/gitnow"
|
||||||
homepage = "https://gitnow-client.prod.kjuulh.app"
|
homepage = "https://gitnow-client.prod.kjuulh.app"
|
||||||
@@ -24,8 +24,9 @@ uuid = { version = "1.7.0", features = ["v4"] }
|
|||||||
async-trait = "0.1.82"
|
async-trait = "0.1.82"
|
||||||
toml = "0.8.19"
|
toml = "0.8.19"
|
||||||
|
|
||||||
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls-webpki-roots", "json"] }
|
gitea-client = { version = "1.22.1" }
|
||||||
url = "2.5.2"
|
url = "2.5.2"
|
||||||
|
octocrab = "0.43.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"
|
||||||
@@ -35,11 +36,6 @@ ratatui = { version = "0.29.0", features = ["termwiz"] }
|
|||||||
crossterm = { version = "0.28.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"
|
|
||||||
minijinja = "2"
|
|
||||||
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"
|
||||||
|
|||||||
@@ -4,30 +4,19 @@ function git-now {
|
|||||||
nohup gitnow update > /dev/null 2>&1 &
|
nohup gitnow update > /dev/null 2>&1 &
|
||||||
)
|
)
|
||||||
|
|
||||||
# Create a temporary chooser file
|
# Find the repository of choice
|
||||||
local chooser_file
|
choice=$(gitnow "$@" --no-shell)
|
||||||
chooser_file="$(mktemp)"
|
if [[ $? -ne 0 ]]; then
|
||||||
|
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
|
||||||
|
|
||||||
# If the chooser file has content, cd to the chosen path
|
# Enter local repository path
|
||||||
if [[ -s "$chooser_file" ]]; then
|
cd "$(echo "$choice" | tail --lines 1)"
|
||||||
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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -105,22 +105,6 @@ 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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,34 +0,0 @@
|
|||||||
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(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,85 +1,3 @@
|
|||||||
pub mod project;
|
|
||||||
pub mod root;
|
pub mod root;
|
||||||
pub mod shell;
|
pub mod shell;
|
||||||
pub mod update;
|
pub mod update;
|
||||||
pub mod worktree;
|
|
||||||
pub mod clone {
|
|
||||||
|
|
||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
use futures::future::join_all;
|
|
||||||
use regex::Regex;
|
|
||||||
|
|
||||||
use crate::{
|
|
||||||
app::App, cache::CacheApp, custom_command::CustomCommandApp, git_clone::GitCloneApp,
|
|
||||||
};
|
|
||||||
|
|
||||||
#[derive(clap::Parser)]
|
|
||||||
pub struct CloneCommand {
|
|
||||||
#[arg(long = "search")]
|
|
||||||
search: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl CloneCommand {
|
|
||||||
pub async fn execute(&mut self, app: &'static App) -> anyhow::Result<()> {
|
|
||||||
let repos = app.cache().get().await?.ok_or(anyhow::anyhow!(
|
|
||||||
"failed to get cache, do a gitnow update first"
|
|
||||||
))?;
|
|
||||||
|
|
||||||
let search = Regex::new(&self.search)?;
|
|
||||||
|
|
||||||
let filtered_repos = repos
|
|
||||||
.iter()
|
|
||||||
.filter(|r| search.is_match(&r.to_rel_path().display().to_string()))
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
|
|
||||||
let concurrency_limit = Arc::new(tokio::sync::Semaphore::new(5));
|
|
||||||
let mut handles = Vec::default();
|
|
||||||
for repo in filtered_repos {
|
|
||||||
let config = app.config.clone();
|
|
||||||
let custom_command = app.custom_command();
|
|
||||||
let git_clone = app.git_clone();
|
|
||||||
let repo = repo.clone();
|
|
||||||
let concurrency = Arc::clone(&concurrency_limit);
|
|
||||||
|
|
||||||
let handle = tokio::spawn(async move {
|
|
||||||
let permit = concurrency.acquire().await?;
|
|
||||||
|
|
||||||
let project_path = config.settings.projects.directory.join(repo.to_rel_path());
|
|
||||||
if !project_path.exists() {
|
|
||||||
eprintln!("cloning repository: {}", repo.to_rel_path().display());
|
|
||||||
git_clone.clone_repo(&repo, false).await?;
|
|
||||||
|
|
||||||
custom_command
|
|
||||||
.execute_post_clone_command(&project_path)
|
|
||||||
.await?;
|
|
||||||
}
|
|
||||||
|
|
||||||
drop(permit);
|
|
||||||
|
|
||||||
Ok::<(), anyhow::Error>(())
|
|
||||||
});
|
|
||||||
|
|
||||||
handles.push(handle);
|
|
||||||
}
|
|
||||||
|
|
||||||
let res = join_all(handles).await;
|
|
||||||
|
|
||||||
for res in res {
|
|
||||||
match res {
|
|
||||||
Ok(Ok(())) => {}
|
|
||||||
Ok(Err(e)) => {
|
|
||||||
tracing::error!("failed to clone repo: {}", e);
|
|
||||||
anyhow::bail!(e)
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
tracing::error!("failed to clone repo: {}", e);
|
|
||||||
anyhow::bail!(e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,515 +0,0 @@
|
|||||||
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(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,8 +2,7 @@ use std::{collections::BTreeMap, io::IsTerminal};
|
|||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
app::App,
|
app::App,
|
||||||
cache::{load_repositories, CacheApp},
|
cache::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},
|
||||||
@@ -32,17 +31,32 @@ 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 {
|
||||||
@@ -119,28 +133,58 @@ 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());
|
||||||
chooser.set(&self.app.config.settings.projects.directory.join(repo.to_rel_path()))?;
|
println!(
|
||||||
|
"{}",
|
||||||
|
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: BTreeMap<String, &Repository> = repositories
|
let haystack = repositories
|
||||||
.iter()
|
.iter()
|
||||||
.map(|r| (r.to_rel_path().display().to_string(), r))
|
.map(|r| (r.to_rel_path().display().to_string(), r))
|
||||||
.collect();
|
.collect::<BTreeMap<_, _>>();
|
||||||
let keys: Vec<&str> = haystack.keys().map(|s| s.as_str()).collect();
|
let haystack_keys = haystack.keys().collect::<Vec<_>>();
|
||||||
|
let haystack_keys = haystack_keys.as_str_vec();
|
||||||
|
|
||||||
self.match_pattern(pattern, &keys)
|
let res = self.match_pattern(pattern, &haystack_keys);
|
||||||
|
|
||||||
|
let matched_repos = res
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter_map(|key| haystack.get(key).map(|r| (*r).to_owned()))
|
.filter_map(|repo_key| haystack.get(repo_key).map(|r| (*r).to_owned()))
|
||||||
.collect()
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
matched_repos
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,154 +0,0 @@
|
|||||||
use std::io::IsTerminal;
|
|
||||||
|
|
||||||
use crate::{
|
|
||||||
app::App,
|
|
||||||
cache::load_repositories,
|
|
||||||
chooser::Chooser,
|
|
||||||
components::inline_command::InlineCommand,
|
|
||||||
fuzzy_matcher::FuzzyMatcherApp,
|
|
||||||
interactive::{InteractiveApp, StringItem},
|
|
||||||
shell::ShellApp,
|
|
||||||
worktree::{sanitize_branch_name, WorktreeApp},
|
|
||||||
};
|
|
||||||
|
|
||||||
use super::root::RepositoryMatcher;
|
|
||||||
|
|
||||||
#[derive(clap::Parser)]
|
|
||||||
pub struct WorktreeCommand {
|
|
||||||
/// Optional search string to pre-filter repositories
|
|
||||||
#[arg()]
|
|
||||||
search: Option<String>,
|
|
||||||
|
|
||||||
/// Branch to check out (skips interactive branch selection)
|
|
||||||
#[arg(long = "branch", short = 'b')]
|
|
||||||
branch: Option<String>,
|
|
||||||
|
|
||||||
/// Skip cache
|
|
||||||
#[arg(long = "no-cache", default_value = "false")]
|
|
||||||
no_cache: bool,
|
|
||||||
|
|
||||||
/// Skip spawning a shell in the worktree
|
|
||||||
#[arg(long = "no-shell", default_value = "false")]
|
|
||||||
no_shell: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl WorktreeCommand {
|
|
||||||
pub async fn execute(&mut self, app: &'static App, chooser: &Chooser) -> anyhow::Result<()> {
|
|
||||||
// Step 1: Load repositories
|
|
||||||
let repositories = load_repositories(app, !self.no_cache).await?;
|
|
||||||
|
|
||||||
// Step 2: Select repository
|
|
||||||
let repo = match &self.search {
|
|
||||||
Some(needle) => {
|
|
||||||
let matched_repos = app
|
|
||||||
.fuzzy_matcher()
|
|
||||||
.match_repositories(needle, &repositories);
|
|
||||||
|
|
||||||
matched_repos
|
|
||||||
.first()
|
|
||||||
.ok_or(anyhow::anyhow!("failed to find repository"))?
|
|
||||||
.to_owned()
|
|
||||||
}
|
|
||||||
None => app
|
|
||||||
.interactive()
|
|
||||||
.interactive_search(&repositories)?
|
|
||||||
.ok_or(anyhow::anyhow!("failed to find a repository"))?,
|
|
||||||
};
|
|
||||||
|
|
||||||
tracing::debug!("selected repo: {}", repo.to_rel_path().display());
|
|
||||||
|
|
||||||
let wt = app.worktree();
|
|
||||||
let (_project_path, bare_path) = wt.paths(&repo);
|
|
||||||
|
|
||||||
// Step 3: Ensure bare clone exists
|
|
||||||
if !bare_path.exists() {
|
|
||||||
if std::io::stdout().is_terminal() && !self.no_shell {
|
|
||||||
let mut wrap_cmd =
|
|
||||||
InlineCommand::new(format!("cloning: {}", repo.to_rel_path().display()));
|
|
||||||
let wt = app.worktree();
|
|
||||||
let repo_clone = repo.clone();
|
|
||||||
let bare_path_clone = bare_path.clone();
|
|
||||||
wrap_cmd
|
|
||||||
.execute(move || async move {
|
|
||||||
wt.ensure_bare_clone(&repo_clone, &bare_path_clone).await?;
|
|
||||||
Ok(())
|
|
||||||
})
|
|
||||||
.await?;
|
|
||||||
} else {
|
|
||||||
eprintln!("bare-cloning repository...");
|
|
||||||
wt.ensure_bare_clone(&repo, &bare_path).await?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 4: List branches
|
|
||||||
let branches = app.worktree().list_branches(&bare_path).await?;
|
|
||||||
|
|
||||||
if branches.is_empty() {
|
|
||||||
anyhow::bail!("no branches found for {}", repo.to_rel_path().display());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 5: Select branch
|
|
||||||
let branch = match &self.branch {
|
|
||||||
Some(b) => {
|
|
||||||
if !branches.contains(b) {
|
|
||||||
anyhow::bail!(
|
|
||||||
"branch '{}' not found. Available branches: {}",
|
|
||||||
b,
|
|
||||||
branches.join(", ")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
b.clone()
|
|
||||||
}
|
|
||||||
None => {
|
|
||||||
let items: Vec<StringItem> =
|
|
||||||
branches.into_iter().map(StringItem).collect();
|
|
||||||
|
|
||||||
let selected = app
|
|
||||||
.interactive()
|
|
||||||
.interactive_search_items(&items)?
|
|
||||||
.ok_or(anyhow::anyhow!("no branch selected"))?;
|
|
||||||
|
|
||||||
selected.0
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Step 6: Create worktree at <project_path>/<sanitized_branch>/
|
|
||||||
let sanitized = sanitize_branch_name(&branch);
|
|
||||||
let (project_path, _) = app.worktree().paths(&repo);
|
|
||||||
let worktree_path = project_path.join(&sanitized);
|
|
||||||
|
|
||||||
if !worktree_path.exists() {
|
|
||||||
if std::io::stdout().is_terminal() && !self.no_shell {
|
|
||||||
let mut wrap_cmd =
|
|
||||||
InlineCommand::new(format!("creating worktree: {}", &branch));
|
|
||||||
let wt = app.worktree();
|
|
||||||
let bare_path = bare_path.clone();
|
|
||||||
let worktree_path = worktree_path.clone();
|
|
||||||
let branch = branch.clone();
|
|
||||||
wrap_cmd
|
|
||||||
.execute(move || async move {
|
|
||||||
wt.add_worktree(&bare_path, &worktree_path, &branch)
|
|
||||||
.await?;
|
|
||||||
Ok(())
|
|
||||||
})
|
|
||||||
.await?;
|
|
||||||
} else {
|
|
||||||
eprintln!("creating worktree for branch '{}'...", &branch);
|
|
||||||
app.worktree()
|
|
||||||
.add_worktree(&bare_path, &worktree_path, &branch)
|
|
||||||
.await?;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
tracing::info!("worktree already exists at {}", worktree_path.display());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 7: Enter shell or print path
|
|
||||||
if !self.no_shell && !chooser.is_active() {
|
|
||||||
app.shell().spawn_shell_at(&worktree_path).await?;
|
|
||||||
} else {
|
|
||||||
chooser.set(&worktree_path)?;
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -3,16 +3,18 @@ use std::time::Duration;
|
|||||||
use crossterm::event::{EventStream, KeyCode};
|
use crossterm::event::{EventStream, KeyCode};
|
||||||
use futures::{FutureExt, StreamExt};
|
use futures::{FutureExt, StreamExt};
|
||||||
use ratatui::{
|
use ratatui::{
|
||||||
TerminalOptions, Viewport, crossterm,
|
crossterm,
|
||||||
prelude::*,
|
prelude::*,
|
||||||
widgets::{Block, Padding},
|
widgets::{Block, Padding},
|
||||||
|
TerminalOptions, Viewport,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::components::BatchCommand;
|
use crate::components::BatchCommand;
|
||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
Dispatch, IntoCommand, Msg, Receiver, create_dispatch,
|
create_dispatch,
|
||||||
spinner::{Spinner, SpinnerState},
|
spinner::{Spinner, SpinnerState},
|
||||||
|
Dispatch, IntoCommand, Msg, Receiver,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub struct InlineCommand {
|
pub struct InlineCommand {
|
||||||
@@ -118,7 +120,7 @@ impl InlineCommand {
|
|||||||
return Ok(true);
|
return Ok(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut cmd = self.update_state(msg);
|
let mut cmd = self.update_state(&msg);
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
let msg = cmd.into_command().execute(dispatch);
|
let msg = cmd.into_command().execute(dispatch);
|
||||||
@@ -126,7 +128,7 @@ impl InlineCommand {
|
|||||||
match msg {
|
match msg {
|
||||||
Some(Msg::Quit) => return Ok(true),
|
Some(Msg::Quit) => return Ok(true),
|
||||||
Some(msg) => {
|
Some(msg) => {
|
||||||
cmd = self.update_state(msg);
|
cmd = self.update_state(&msg);
|
||||||
}
|
}
|
||||||
None => break,
|
None => break,
|
||||||
}
|
}
|
||||||
@@ -161,7 +163,7 @@ impl InlineCommand {
|
|||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
fn update_state(&mut self, msg: Msg) -> impl IntoCommand {
|
fn update_state(&mut self, msg: &Msg) -> impl IntoCommand {
|
||||||
tracing::debug!("handling message: {:?}", msg);
|
tracing::debug!("handling message: {:?}", msg);
|
||||||
|
|
||||||
let mut batch = BatchCommand::default();
|
let mut batch = BatchCommand::default();
|
||||||
@@ -176,7 +178,7 @@ impl InlineCommand {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
batch.with(self.spinner.update(&msg));
|
batch.with(self.spinner.update(msg));
|
||||||
|
|
||||||
batch.into_command()
|
batch.into_command()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,67 +22,39 @@ pub struct Settings {
|
|||||||
|
|
||||||
pub post_clone_command: Option<PostCloneCommand>,
|
pub post_clone_command: Option<PostCloneCommand>,
|
||||||
pub post_update_command: Option<PostUpdateCommand>,
|
pub post_update_command: Option<PostUpdateCommand>,
|
||||||
|
|
||||||
/// Minijinja template for the clone command.
|
|
||||||
/// Default: "git clone {{ ssh_url }} {{ path }}"
|
|
||||||
pub clone_command: Option<String>,
|
|
||||||
|
|
||||||
/// Worktree configuration.
|
|
||||||
#[serde(default)]
|
|
||||||
pub worktree: Option<WorktreeSettings>,
|
|
||||||
|
|
||||||
/// 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)]
|
|
||||||
pub struct WorktreeSettings {
|
|
||||||
/// Template for bare-cloning a repository.
|
|
||||||
/// Default: "git clone --bare {{ ssh_url }} {{ bare_path }}"
|
|
||||||
pub clone_command: Option<String>,
|
|
||||||
|
|
||||||
/// Template for adding a worktree.
|
|
||||||
/// Default: "git -C {{ bare_path }} worktree add {{ worktree_path }} {{ branch }}"
|
|
||||||
pub add_command: Option<String>,
|
|
||||||
|
|
||||||
/// Template for listing branches.
|
|
||||||
/// Default: "git -C {{ bare_path }} branch -r --format=%(refname:short)"
|
|
||||||
pub list_branches_command: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 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 CommandList {
|
pub enum PostUpdateCommand {
|
||||||
Single(String),
|
Single(String),
|
||||||
Multiple(Vec<String>),
|
Multiple(Vec<String>),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl CommandList {
|
impl PostUpdateCommand {
|
||||||
pub fn get_commands(&self) -> Vec<String> {
|
pub fn get_commands(&self) -> Vec<String> {
|
||||||
match self.clone() {
|
match self.clone() {
|
||||||
CommandList::Single(item) => vec![item],
|
PostUpdateCommand::Single(item) => vec![item],
|
||||||
CommandList::Multiple(items) => items,
|
PostUpdateCommand::Multiple(items) => items,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Backwards-compatible type aliases.
|
#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
|
||||||
pub type PostCloneCommand = CommandList;
|
#[serde(untagged)]
|
||||||
pub type PostUpdateCommand = CommandList;
|
pub enum PostCloneCommand {
|
||||||
|
Single(String),
|
||||||
|
Multiple(Vec<String>),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PostCloneCommand {
|
||||||
|
pub fn get_commands(&self) -> Vec<String> {
|
||||||
|
match self.clone() {
|
||||||
|
PostCloneCommand::Single(item) => vec![item],
|
||||||
|
PostCloneCommand::Multiple(items) => items,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Default, Serialize, Deserialize, PartialEq, Clone)]
|
#[derive(Debug, Default, Serialize, Deserialize, PartialEq, Clone)]
|
||||||
pub struct Projects {
|
pub struct Projects {
|
||||||
@@ -176,7 +148,7 @@ impl CacheDuration {
|
|||||||
hours,
|
hours,
|
||||||
minutes,
|
minutes,
|
||||||
} => Some(
|
} => Some(
|
||||||
std::time::Duration::from_hours(*days * 24)
|
std::time::Duration::from_days(*days)
|
||||||
+ std::time::Duration::from_hours(*hours)
|
+ std::time::Duration::from_hours(*hours)
|
||||||
+ std::time::Duration::from_mins(*minutes),
|
+ std::time::Duration::from_mins(*minutes),
|
||||||
),
|
),
|
||||||
@@ -218,28 +190,35 @@ pub struct GitHub {
|
|||||||
pub organisations: Vec<GitHubOrganisation>,
|
pub organisations: Vec<GitHubOrganisation>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Generates a newtype wrapper around `String` with `From` impls for owned and borrowed access.
|
#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
|
||||||
macro_rules! string_newtype {
|
pub struct GitHubUser(String);
|
||||||
($name:ident) => {
|
|
||||||
#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
|
|
||||||
pub struct $name(String);
|
|
||||||
|
|
||||||
impl From<$name> for String {
|
impl From<GitHubUser> for String {
|
||||||
fn from(value: $name) -> Self {
|
fn from(value: GitHubUser) -> Self {
|
||||||
value.0
|
value.0
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
impl<'a> From<&'a $name> for &'a str {
|
|
||||||
fn from(value: &'a $name) -> Self {
|
|
||||||
value.0.as_str()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
string_newtype!(GitHubUser);
|
impl<'a> From<&'a GitHubUser> for &'a str {
|
||||||
string_newtype!(GitHubOrganisation);
|
fn from(value: &'a GitHubUser) -> Self {
|
||||||
|
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 {
|
||||||
@@ -271,8 +250,34 @@ pub enum GitHubAccessToken {
|
|||||||
Env { env: String },
|
Env { env: String },
|
||||||
}
|
}
|
||||||
|
|
||||||
string_newtype!(GiteaUser);
|
#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
|
||||||
string_newtype!(GiteaOrganisation);
|
pub struct GiteaUser(String);
|
||||||
|
|
||||||
|
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> {
|
||||||
@@ -393,10 +398,7 @@ mod test {
|
|||||||
directory: PathBuf::from("git").into()
|
directory: PathBuf::from("git").into()
|
||||||
},
|
},
|
||||||
post_update_command: None,
|
post_update_command: None,
|
||||||
post_clone_command: None,
|
post_clone_command: None
|
||||||
clone_command: None,
|
|
||||||
worktree: None,
|
|
||||||
project: None,
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
config
|
config
|
||||||
@@ -423,10 +425,7 @@ mod test {
|
|||||||
cache: Cache::default(),
|
cache: Cache::default(),
|
||||||
projects: Projects::default(),
|
projects: Projects::default(),
|
||||||
post_update_command: None,
|
post_update_command: None,
|
||||||
post_clone_command: None,
|
post_clone_command: None
|
||||||
clone_command: None,
|
|
||||||
worktree: None,
|
|
||||||
project: None,
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
config
|
config
|
||||||
@@ -434,54 +433,4 @@ mod test {
|
|||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_can_parse_config_with_clone_command() -> anyhow::Result<()> {
|
|
||||||
let content = r#"
|
|
||||||
[settings]
|
|
||||||
projects = { directory = "git" }
|
|
||||||
clone_command = "jj git clone {{ ssh_url }} {{ path }}"
|
|
||||||
"#;
|
|
||||||
|
|
||||||
let config = Config::from_string(content)?;
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
config.settings.clone_command,
|
|
||||||
Some("jj git clone {{ ssh_url }} {{ path }}".to_string())
|
|
||||||
);
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_can_parse_config_with_worktree() -> anyhow::Result<()> {
|
|
||||||
let content = r#"
|
|
||||||
[settings]
|
|
||||||
projects = { directory = "git" }
|
|
||||||
|
|
||||||
[settings.worktree]
|
|
||||||
clone_command = "jj git clone {{ ssh_url }} {{ bare_path }}"
|
|
||||||
add_command = "jj workspace add --name {{ branch }} {{ worktree_path }}"
|
|
||||||
list_branches_command = "jj -R {{ bare_path }} branch list"
|
|
||||||
"#;
|
|
||||||
|
|
||||||
let config = Config::from_string(content)?;
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
config.settings.worktree,
|
|
||||||
Some(WorktreeSettings {
|
|
||||||
clone_command: Some(
|
|
||||||
"jj git clone {{ ssh_url }} {{ bare_path }}".to_string()
|
|
||||||
),
|
|
||||||
add_command: Some(
|
|
||||||
"jj workspace add --name {{ branch }} {{ worktree_path }}".to_string()
|
|
||||||
),
|
|
||||||
list_branches_command: Some(
|
|
||||||
"jj -R {{ bare_path }} branch list".to_string()
|
|
||||||
),
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,26 +1,19 @@
|
|||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
use crate::{app::App, config::CommandList};
|
use crate::{app::App, config::Config};
|
||||||
|
|
||||||
pub struct CustomCommand {
|
pub struct CustomCommand {
|
||||||
post_clone: Option<CommandList>,
|
config: Config,
|
||||||
post_update: Option<CommandList>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl CustomCommand {
|
impl CustomCommand {
|
||||||
pub fn new(app: &App) -> Self {
|
pub fn new(config: Config) -> Self {
|
||||||
Self {
|
Self { config }
|
||||||
post_clone: app.config.settings.post_clone_command.clone(),
|
|
||||||
post_update: app.config.settings.post_update_command.clone(),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn execute_commands(
|
pub async fn execute_post_clone_command(&self, path: &Path) -> anyhow::Result<()> {
|
||||||
commands: &CommandList,
|
if let Some(post_clone_command) = &self.config.settings.post_clone_command {
|
||||||
path: &Path,
|
for command in post_clone_command.get_commands() {
|
||||||
label: &str,
|
|
||||||
) -> anyhow::Result<()> {
|
|
||||||
for command in commands.get_commands() {
|
|
||||||
let command_parts = command.split(' ').collect::<Vec<_>>();
|
let command_parts = command.split(' ').collect::<Vec<_>>();
|
||||||
let Some((first, rest)) = command_parts.split_first() else {
|
let Some((first, rest)) = command_parts.split_first() else {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
@@ -34,32 +27,47 @@ impl CustomCommand {
|
|||||||
tracing::info!(
|
tracing::info!(
|
||||||
path = path.display().to_string(),
|
path = path.display().to_string(),
|
||||||
cmd = command,
|
cmd = command,
|
||||||
"running custom {} command",
|
"running custom post clone command"
|
||||||
label
|
|
||||||
);
|
);
|
||||||
let output = cmd.output().await?;
|
let output = cmd.output().await?;
|
||||||
let stdout = std::str::from_utf8(&output.stdout)?;
|
let stdout = std::str::from_utf8(&output.stdout)?;
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
stdout = stdout,
|
stdout = stdout,
|
||||||
"finished running custom {} command",
|
"finished running custom post clone command"
|
||||||
label
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn execute_post_clone_command(&self, path: &Path) -> anyhow::Result<()> {
|
|
||||||
if let Some(ref cmds) = self.post_clone {
|
|
||||||
Self::execute_commands(cmds, path, "post clone").await?;
|
|
||||||
}
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn execute_post_update_command(&self, path: &Path) -> anyhow::Result<()> {
|
pub async fn execute_post_update_command(&self, path: &Path) -> anyhow::Result<()> {
|
||||||
if let Some(ref cmds) = self.post_update {
|
if let Some(post_update_command) = &self.config.settings.post_update_command {
|
||||||
Self::execute_commands(cmds, path, "post update").await?;
|
for command in post_update_command.get_commands() {
|
||||||
|
let command_parts = command.split(' ').collect::<Vec<_>>();
|
||||||
|
let Some((first, rest)) = command_parts.split_first() else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut cmd = tokio::process::Command::new(first);
|
||||||
|
cmd.args(rest).current_dir(path);
|
||||||
|
|
||||||
|
eprintln!("running command: {}", command);
|
||||||
|
|
||||||
|
tracing::info!(
|
||||||
|
path = path.display().to_string(),
|
||||||
|
cmd = command,
|
||||||
|
"running custom post update command"
|
||||||
|
);
|
||||||
|
let output = cmd.output().await?;
|
||||||
|
let stdout = std::str::from_utf8(&output.stdout)?;
|
||||||
|
tracing::info!(
|
||||||
|
stdout = stdout,
|
||||||
|
"finished running custom post update command"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -70,6 +78,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)
|
CustomCommand::new(self.config.clone())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
use std::collections::HashMap;
|
use crate::{app::App, git_provider::Repository};
|
||||||
|
|
||||||
use crate::{app::App, git_provider::Repository, template_command};
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct GitClone {
|
pub struct GitClone {
|
||||||
@@ -37,27 +35,20 @@ impl GitClone {
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
let template = self
|
|
||||||
.app
|
|
||||||
.config
|
|
||||||
.settings
|
|
||||||
.clone_command
|
|
||||||
.as_deref()
|
|
||||||
.unwrap_or(template_command::DEFAULT_CLONE_COMMAND);
|
|
||||||
|
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
"cloning: {} into {}",
|
"cloning: {} into {}",
|
||||||
repository.ssh_url.as_str(),
|
repository.ssh_url.as_str(),
|
||||||
&project_path.display().to_string(),
|
&project_path.display().to_string(),
|
||||||
);
|
);
|
||||||
|
|
||||||
let path_str = project_path.display().to_string();
|
let mut cmd = tokio::process::Command::new("git");
|
||||||
let context = HashMap::from([
|
cmd.args([
|
||||||
("ssh_url", repository.ssh_url.as_str()),
|
"clone",
|
||||||
("path", path_str.as_str()),
|
repository.ssh_url.as_str(),
|
||||||
|
&project_path.display().to_string(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
let output = template_command::render_and_execute(template, context).await?;
|
let output = cmd.output().await?;
|
||||||
match output.status.success() {
|
match output.status.success() {
|
||||||
true => tracing::debug!(
|
true => tracing::debug!(
|
||||||
"cloned {} into {}",
|
"cloned {} into {}",
|
||||||
|
|||||||
@@ -1,34 +1,18 @@
|
|||||||
use anyhow::Context;
|
use anyhow::Context;
|
||||||
use serde::Deserialize;
|
use gitea_client::apis::configuration::Configuration;
|
||||||
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 {
|
GiteaProvider { app }
|
||||||
app,
|
|
||||||
client: reqwest::Client::new(),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn list_repositories_for_current_user(
|
pub async fn list_repositories_for_current_user(
|
||||||
@@ -38,130 +22,26 @@ 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 repos: Vec<GiteaRepo> = self
|
let mut repos = self
|
||||||
.request(&format!("{api}/user/repos"), access_token, page)
|
.list_repositories_for_current_user_with_page(&config, page)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
if repos.is_empty() {
|
if repos.is_empty() {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
repositories.extend(repos);
|
repositories.append(&mut repos);
|
||||||
page += 1;
|
page += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
let provider = &Self::get_domain(api)?;
|
let provider = &Self::get_domain(api)?;
|
||||||
Ok(to_repositories(provider, repositories))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn get_domain(api: &str) -> anyhow::Result<String> {
|
Ok(repositories
|
||||||
let url = Url::parse(api)?;
|
|
||||||
let provider = url.domain().unwrap_or("gitea");
|
|
||||||
|
|
||||||
Ok(provider.into())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn list_repositories_for_user(
|
|
||||||
&self,
|
|
||||||
user: &str,
|
|
||||||
api: &str,
|
|
||||||
access_token: Option<&GiteaAccessToken>,
|
|
||||||
) -> anyhow::Result<Vec<super::Repository>> {
|
|
||||||
tracing::debug!(user = user, "fetching gitea repositories for user");
|
|
||||||
|
|
||||||
let mut repositories = Vec::new();
|
|
||||||
let mut page = 1;
|
|
||||||
loop {
|
|
||||||
let repos: Vec<GiteaRepo> = self
|
|
||||||
.request(
|
|
||||||
&format!("{api}/users/{user}/repos"),
|
|
||||||
access_token,
|
|
||||||
page,
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
if repos.is_empty() {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
repositories.extend(repos);
|
|
||||||
page += 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
let provider = &Self::get_domain(api)?;
|
|
||||||
Ok(to_repositories(provider, repositories))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn list_repositories_for_organisation(
|
|
||||||
&self,
|
|
||||||
organisation: &str,
|
|
||||||
api: &str,
|
|
||||||
access_token: Option<&GiteaAccessToken>,
|
|
||||||
) -> anyhow::Result<Vec<super::Repository>> {
|
|
||||||
tracing::debug!(
|
|
||||||
organisation = organisation,
|
|
||||||
"fetching gitea repositories for organisation"
|
|
||||||
);
|
|
||||||
|
|
||||||
let mut repositories = Vec::new();
|
|
||||||
let mut page = 1;
|
|
||||||
loop {
|
|
||||||
let repos: Vec<GiteaRepo> = self
|
|
||||||
.request(
|
|
||||||
&format!("{api}/orgs/{organisation}/repos"),
|
|
||||||
access_token,
|
|
||||||
page,
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
if repos.is_empty() {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
repositories.extend(repos);
|
|
||||||
page += 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
let provider = &Self::get_domain(api)?;
|
|
||||||
Ok(to_repositories(provider, repositories))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn request<T: serde::de::DeserializeOwned>(
|
|
||||||
&self,
|
|
||||||
url: &str,
|
|
||||||
access_token: Option<&GiteaAccessToken>,
|
|
||||||
page: usize,
|
|
||||||
) -> anyhow::Result<T> {
|
|
||||||
let mut req = self.client.get(url).query(&[("page", page.to_string())]);
|
|
||||||
|
|
||||||
match access_token {
|
|
||||||
Some(GiteaAccessToken::Env { env }) => {
|
|
||||||
let token =
|
|
||||||
std::env::var(env).context(format!("{env} didn't have a valid value"))?;
|
|
||||||
req = req.basic_auth("", Some(token));
|
|
||||||
}
|
|
||||||
Some(GiteaAccessToken::Direct(var)) => {
|
|
||||||
req = req.bearer_auth(var);
|
|
||||||
}
|
|
||||||
None => {}
|
|
||||||
}
|
|
||||||
|
|
||||||
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()
|
.into_iter()
|
||||||
.map(|repo| super::Repository {
|
.map(|repo| super::Repository {
|
||||||
provider: provider.into(),
|
provider: provider.into(),
|
||||||
@@ -174,7 +54,171 @@ fn to_repositories(provider: &str, repos: Vec<GiteaRepo>) -> Vec<super::Reposito
|
|||||||
.ssh_url
|
.ssh_url
|
||||||
.expect("ssh url to be set for a gitea repository"),
|
.expect("ssh url to be set for a gitea repository"),
|
||||||
})
|
})
|
||||||
.collect()
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_domain(api: &str) -> anyhow::Result<String> {
|
||||||
|
let url = Url::parse(api)?;
|
||||||
|
let provider = url.domain().unwrap_or("gitea");
|
||||||
|
|
||||||
|
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(
|
||||||
|
&self,
|
||||||
|
user: &str,
|
||||||
|
api: &str,
|
||||||
|
access_token: Option<&GiteaAccessToken>,
|
||||||
|
) -> anyhow::Result<Vec<super::Repository>> {
|
||||||
|
tracing::debug!(user = user, "fetching gitea repositories for user");
|
||||||
|
|
||||||
|
let config = self.get_config(api, access_token)?;
|
||||||
|
|
||||||
|
let mut repositories = Vec::new();
|
||||||
|
let mut page = 1;
|
||||||
|
loop {
|
||||||
|
let mut repos = self
|
||||||
|
.list_repositories_for_user_with_page(user, &config, page)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if repos.is_empty() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
repositories.append(&mut repos);
|
||||||
|
page += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
let provider = &Self::get_domain(api)?;
|
||||||
|
|
||||||
|
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(
|
||||||
|
&self,
|
||||||
|
organisation: &str,
|
||||||
|
api: &str,
|
||||||
|
access_token: Option<&GiteaAccessToken>,
|
||||||
|
) -> anyhow::Result<Vec<super::Repository>> {
|
||||||
|
tracing::debug!(
|
||||||
|
organisation = organisation,
|
||||||
|
"fetching gitea repositories for organisation"
|
||||||
|
);
|
||||||
|
let config = self.get_config(api, access_token)?;
|
||||||
|
|
||||||
|
let mut repositories = Vec::new();
|
||||||
|
let mut page = 1;
|
||||||
|
loop {
|
||||||
|
let mut repos = self
|
||||||
|
.list_repositories_for_organisation_with_page(organisation, &config, page)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if repos.is_empty() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
repositories.append(&mut repos);
|
||||||
|
page += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
let provider = &Self::get_domain(api)?;
|
||||||
|
|
||||||
|
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(
|
||||||
|
&self,
|
||||||
|
organisation: &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>,
|
||||||
|
) -> anyhow::Result<Configuration> {
|
||||||
|
let mut config = gitea_client::apis::configuration::Configuration::new();
|
||||||
|
config.base_path = api.into();
|
||||||
|
match access_token {
|
||||||
|
Some(GiteaAccessToken::Env { env }) => {
|
||||||
|
let token =
|
||||||
|
std::env::var(env).context(format!("{env} didn't have a valid value"))?;
|
||||||
|
|
||||||
|
config.basic_auth = Some(("".into(), Some(token)));
|
||||||
|
}
|
||||||
|
Some(GiteaAccessToken::Direct(var)) => {
|
||||||
|
config.bearer_access_token = Some(var.to_owned());
|
||||||
|
}
|
||||||
|
None => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(config)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub trait GiteaProviderApp {
|
pub trait GiteaProviderApp {
|
||||||
|
|||||||
@@ -1,20 +1,7 @@
|
|||||||
use reqwest::header::{ACCEPT, AUTHORIZATION, USER_AGENT};
|
use octocrab::{models::Repository, params::repos::Sort, Octocrab, Page};
|
||||||
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,
|
||||||
@@ -32,17 +19,30 @@ 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(access_token)?;
|
let client = self.get_client(url, access_token)?;
|
||||||
let base = self.api_base(url);
|
|
||||||
|
|
||||||
let repos: Vec<GitHubRepo> = self
|
let current_page = client
|
||||||
.paginate(
|
.current()
|
||||||
&client,
|
.list_repos_for_authenticated_user()
|
||||||
&format!("{base}/user/repos?type=all&sort=full_name&per_page=100"),
|
.type_("all")
|
||||||
)
|
.per_page(100)
|
||||||
|
.sort("full_name")
|
||||||
|
.send()
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
Ok(self.to_repositories(url, repos))
|
let repos = self.unfold_pages(client, current_page).await?;
|
||||||
|
|
||||||
|
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,17 +53,30 @@ 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(access_token)?;
|
let client = self.get_client(url, access_token)?;
|
||||||
let base = self.api_base(url);
|
|
||||||
|
|
||||||
let repos: Vec<GitHubRepo> = self
|
let current_page = client
|
||||||
.paginate(
|
.users(user)
|
||||||
&client,
|
.repos()
|
||||||
&format!("{base}/users/{user}/repos?type=all&sort=full_name&per_page=100"),
|
.r#type(octocrab::params::users::repos::Type::All)
|
||||||
)
|
.sort(Sort::FullName)
|
||||||
|
.per_page(100)
|
||||||
|
.send()
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
Ok(self.to_repositories(url, repos))
|
let repos = self.unfold_pages(client, current_page).await?;
|
||||||
|
|
||||||
|
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(
|
||||||
@@ -77,66 +90,47 @@ impl GitHubProvider {
|
|||||||
"fetching github repositories for organisation"
|
"fetching github repositories for organisation"
|
||||||
);
|
);
|
||||||
|
|
||||||
let client = self.get_client(access_token)?;
|
let client = self.get_client(url, access_token)?;
|
||||||
let base = self.api_base(url);
|
|
||||||
|
|
||||||
let repos: Vec<GitHubRepo> = self
|
let current_page = client
|
||||||
.paginate(
|
.orgs(organisation)
|
||||||
&client,
|
.list_repos()
|
||||||
&format!("{base}/orgs/{organisation}/repos?type=all&sort=full_name&per_page=100"),
|
.repo_type(Some(octocrab::params::repos::Type::All))
|
||||||
)
|
.sort(Sort::FullName)
|
||||||
|
.per_page(100)
|
||||||
|
.send()
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
Ok(self.to_repositories(url, repos))
|
let repos = self.unfold_pages(client, current_page).await?;
|
||||||
}
|
|
||||||
|
|
||||||
async fn paginate(
|
Ok(repos
|
||||||
&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(¤t_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(|o| o.login)?,
|
owner: repo.owner.map(|su| su.login)?,
|
||||||
repo_name: repo.name,
|
repo_name: repo.name,
|
||||||
ssh_url: repo.ssh_url?,
|
ssh_url: repo.ssh_url?,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.collect()
|
.collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn api_base(&self, url: Option<&String>) -> String {
|
async fn unfold_pages(
|
||||||
match url {
|
&self,
|
||||||
Some(u) => format!("{u}/api/v3"),
|
client: octocrab::Octocrab,
|
||||||
None => "https://api.github.com".to_string(),
|
page: Page<Repository>,
|
||||||
|
) -> 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(¤t_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 {
|
||||||
@@ -157,19 +151,15 @@ impl GitHubProvider {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_client(&self, access_token: &GitHubAccessToken) -> anyhow::Result<reqwest::Client> {
|
fn get_client(
|
||||||
let token = match access_token {
|
&self,
|
||||||
|
_url: Option<&String>,
|
||||||
|
access_token: &GitHubAccessToken,
|
||||||
|
) -> anyhow::Result<Octocrab> {
|
||||||
|
let client = octocrab::Octocrab::builder()
|
||||||
|
.personal_token(match access_token {
|
||||||
GitHubAccessToken::Direct(token) => token.to_owned(),
|
GitHubAccessToken::Direct(token) => token.to_owned(),
|
||||||
GitHubAccessToken::Env { env } => std::env::var(env)?,
|
GitHubAccessToken::Env { env } => std::env::var(env)?,
|
||||||
};
|
|
||||||
|
|
||||||
let client = reqwest::Client::builder()
|
|
||||||
.default_headers({
|
|
||||||
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()?;
|
||||||
|
|
||||||
@@ -177,18 +167,6 @@ 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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,25 +3,6 @@ use ratatui::{prelude::*, Terminal};
|
|||||||
|
|
||||||
use crate::git_provider::Repository;
|
use crate::git_provider::Repository;
|
||||||
|
|
||||||
pub trait Searchable: Clone {
|
|
||||||
fn display_label(&self) -> String;
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Searchable for Repository {
|
|
||||||
fn display_label(&self) -> String {
|
|
||||||
self.to_rel_path().display().to_string()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
|
||||||
pub struct StringItem(pub String);
|
|
||||||
|
|
||||||
impl Searchable for StringItem {
|
|
||||||
fn display_label(&self) -> String {
|
|
||||||
self.0.clone()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct Interactive {
|
pub struct Interactive {
|
||||||
app: &'static crate::app::App,
|
app: &'static crate::app::App,
|
||||||
}
|
}
|
||||||
@@ -35,27 +16,12 @@ impl Interactive {
|
|||||||
&mut self,
|
&mut self,
|
||||||
repositories: &[Repository],
|
repositories: &[Repository],
|
||||||
) -> anyhow::Result<Option<Repository>> {
|
) -> anyhow::Result<Option<Repository>> {
|
||||||
self.interactive_search_items(repositories)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn interactive_search_items<T: Searchable>(
|
|
||||||
&mut self,
|
|
||||||
items: &[T],
|
|
||||||
) -> anyhow::Result<Option<T>> {
|
|
||||||
let backend = TermwizBackend::new().map_err(|e| anyhow::anyhow!(e.to_string()))?;
|
let backend = TermwizBackend::new().map_err(|e| anyhow::anyhow!(e.to_string()))?;
|
||||||
let terminal = Terminal::new(backend)?;
|
let terminal = Terminal::new(backend)?;
|
||||||
|
|
||||||
App::new(self.app, items).run(terminal)
|
let app_result = App::new(self.app, repositories).run(terminal);
|
||||||
}
|
|
||||||
|
|
||||||
pub fn interactive_multi_search<T: Searchable>(
|
app_result
|
||||||
&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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,47 +47,36 @@ mod app {
|
|||||||
Frame, Terminal,
|
Frame, Terminal,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::fuzzy_matcher::FuzzyMatcherApp;
|
use crate::{
|
||||||
|
commands::root::RepositoryMatcher, fuzzy_matcher::FuzzyMatcherApp, git_provider::Repository,
|
||||||
|
};
|
||||||
|
|
||||||
use super::Searchable;
|
pub struct App<'a> {
|
||||||
|
|
||||||
pub struct App<'a, T: Searchable> {
|
|
||||||
app: &'static crate::app::App,
|
app: &'static crate::app::App,
|
||||||
items: &'a [T],
|
repositories: &'a [Repository],
|
||||||
current_search: String,
|
current_search: String,
|
||||||
matched_items: Vec<T>,
|
matched_repos: Vec<Repository>,
|
||||||
list: ListState,
|
list: ListState,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<'a, T: Searchable> App<'a, T> {
|
impl<'a> App<'a> {
|
||||||
pub fn new(app: &'static crate::app::App, items: &'a [T]) -> Self {
|
pub fn new(app: &'static crate::app::App, repositories: &'a [Repository]) -> Self {
|
||||||
Self {
|
Self {
|
||||||
app,
|
app,
|
||||||
items,
|
repositories,
|
||||||
current_search: String::default(),
|
current_search: String::default(),
|
||||||
matched_items: Vec::default(),
|
matched_repos: Vec::default(),
|
||||||
list: ListState::default(),
|
list: ListState::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn update_matched_items(&mut self) {
|
fn update_matched_repos(&mut self) {
|
||||||
let labels: Vec<String> = self.items.iter().map(|i| i.display_label()).collect();
|
let res = self
|
||||||
let label_refs: Vec<&str> = labels.iter().map(|s| s.as_str()).collect();
|
|
||||||
|
|
||||||
let matched_keys = self
|
|
||||||
.app
|
.app
|
||||||
.fuzzy_matcher()
|
.fuzzy_matcher()
|
||||||
.match_pattern(&self.current_search, &label_refs);
|
.match_repositories(&self.current_search, self.repositories);
|
||||||
|
|
||||||
self.matched_items = matched_keys
|
self.matched_repos = res;
|
||||||
.into_iter()
|
|
||||||
.filter_map(|key| {
|
|
||||||
self.items
|
|
||||||
.iter()
|
|
||||||
.find(|i| i.display_label() == key)
|
|
||||||
.cloned()
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
if self.list.selected().is_none() {
|
if self.list.selected().is_none() {
|
||||||
self.list.select_first();
|
self.list.select_first();
|
||||||
@@ -131,41 +86,39 @@ mod app {
|
|||||||
pub fn run(
|
pub fn run(
|
||||||
mut self,
|
mut self,
|
||||||
mut terminal: Terminal<TermwizBackend>,
|
mut terminal: Terminal<TermwizBackend>,
|
||||||
) -> anyhow::Result<Option<T>> {
|
) -> anyhow::Result<Option<Repository>> {
|
||||||
self.update_matched_items();
|
self.update_matched_repos();
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
terminal.draw(|frame| self.draw(frame))?;
|
terminal.draw(|frame| self.draw(frame))?;
|
||||||
|
|
||||||
if let Event::Key(key) = event::read()? {
|
if let Event::Key(key) = event::read()? {
|
||||||
if let KeyCode::Char('c') = key.code
|
if let KeyCode::Char('c') = key.code {
|
||||||
&& key.modifiers.contains(KeyModifiers::CONTROL)
|
if key.modifiers.contains(KeyModifiers::CONTROL) {
|
||||||
{
|
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
match key.code {
|
match key.code {
|
||||||
KeyCode::Char(letter) => {
|
KeyCode::Char(letter) => {
|
||||||
self.current_search.push(letter);
|
self.current_search.push(letter);
|
||||||
self.update_matched_items();
|
self.update_matched_repos();
|
||||||
}
|
}
|
||||||
KeyCode::Backspace => {
|
KeyCode::Backspace => {
|
||||||
if !self.current_search.is_empty() {
|
if !self.current_search.is_empty() {
|
||||||
let _ =
|
let _ = self.current_search.remove(self.current_search.len() - 1);
|
||||||
self.current_search.remove(self.current_search.len() - 1);
|
self.update_matched_repos();
|
||||||
self.update_matched_items();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
KeyCode::Esc => {
|
KeyCode::Esc => {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
KeyCode::Enter => {
|
KeyCode::Enter => {
|
||||||
if let Some(selected) = self.list.selected()
|
if let Some(selected) = self.list.selected() {
|
||||||
&& let Some(item) =
|
if let Some(repo) = self.matched_repos.get(selected).cloned() {
|
||||||
self.matched_items.get(selected).cloned()
|
|
||||||
{
|
|
||||||
terminal.resize(ratatui::layout::Rect::ZERO)?;
|
terminal.resize(ratatui::layout::Rect::ZERO)?;
|
||||||
return Ok(Some(item));
|
return Ok(Some(repo));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
@@ -180,207 +133,41 @@ mod app {
|
|||||||
|
|
||||||
fn draw(&mut self, frame: &mut Frame) {
|
fn draw(&mut self, frame: &mut Frame) {
|
||||||
let vertical = Layout::vertical([Constraint::Percentage(100), Constraint::Min(1)]);
|
let vertical = Layout::vertical([Constraint::Percentage(100), Constraint::Min(1)]);
|
||||||
let [list_area, input_area] = vertical.areas(frame.area());
|
let [repository_area, input_area] = vertical.areas(frame.area());
|
||||||
|
|
||||||
let display_items: Vec<String> =
|
let repos = &self.matched_repos;
|
||||||
self.matched_items.iter().map(|i| i.display_label()).collect();
|
|
||||||
|
|
||||||
let list_items: Vec<ListItem> =
|
let repo_items = repos
|
||||||
display_items.into_iter().map(ListItem::from).collect();
|
.iter()
|
||||||
|
.map(|r| r.to_rel_path().display().to_string())
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
let list = ratatui::widgets::List::new(list_items)
|
let repo_list_items = repo_items
|
||||||
.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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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()
|
.into_iter()
|
||||||
.filter_map(|key| {
|
.map(ListItem::from)
|
||||||
self.items
|
.collect::<Vec<_>>();
|
||||||
.iter()
|
|
||||||
.find(|i| i.display_label() == key)
|
|
||||||
.cloned()
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
if self.list.selected().is_none() {
|
let repo_list = ratatui::widgets::List::new(repo_list_items)
|
||||||
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)
|
.direction(ratatui::widgets::ListDirection::BottomToTop)
|
||||||
.scroll_padding(3)
|
.scroll_padding(3)
|
||||||
.highlight_symbol("> ")
|
.highlight_symbol("> ")
|
||||||
.highlight_spacing(ratatui::widgets::HighlightSpacing::Always)
|
.highlight_spacing(ratatui::widgets::HighlightSpacing::Always)
|
||||||
.highlight_style(Style::default().bold().white());
|
.highlight_style(Style::default().bold().white());
|
||||||
|
|
||||||
StatefulWidget::render(list, list_area, frame.buffer_mut(), &mut self.list);
|
StatefulWidget::render(
|
||||||
|
repo_list,
|
||||||
|
repository_area,
|
||||||
|
frame.buffer_mut(),
|
||||||
|
&mut self.list,
|
||||||
|
);
|
||||||
|
|
||||||
let input = Paragraph::new(Line::from(vec![
|
let input = Paragraph::new(Line::from(vec![
|
||||||
Span::from("> ").blue(),
|
Span::from("> ").blue(),
|
||||||
Span::from(self.current_search.as_str()),
|
Span::from(self.current_search.as_str()),
|
||||||
Span::from(" ").on_white(),
|
Span::from(" ").on_white(),
|
||||||
]));
|
]));
|
||||||
frame.render_widget(input, input_area);
|
|
||||||
|
|
||||||
let count = self.selected_labels.len();
|
frame.render_widget(input, input_area);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
|
#![feature(duration_constructors)]
|
||||||
|
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
use anyhow::Context;
|
use anyhow::Context;
|
||||||
use clap::{Parser, Subcommand};
|
use clap::{Parser, Subcommand};
|
||||||
use commands::{
|
use commands::{root::RootCommand, shell::Shell, update::Update};
|
||||||
clone::CloneCommand, project::ProjectCommand, root::RootCommand, shell::Shell, update::Update,
|
|
||||||
worktree::WorktreeCommand,
|
|
||||||
};
|
|
||||||
use config::Config;
|
use config::Config;
|
||||||
use tracing::level_filters::LevelFilter;
|
use tracing::level_filters::LevelFilter;
|
||||||
use tracing_subscriber::EnvFilter;
|
use tracing_subscriber::EnvFilter;
|
||||||
@@ -13,7 +12,6 @@ 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;
|
||||||
@@ -22,11 +20,8 @@ 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 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"))]
|
||||||
@@ -34,10 +29,6 @@ struct Command {
|
|||||||
#[command(subcommand)]
|
#[command(subcommand)]
|
||||||
command: Option<Commands>,
|
command: Option<Commands>,
|
||||||
|
|
||||||
/// Path to config file (default: ~/.config/gitnow/gitnow.toml, or $GITNOW_CONFIG)
|
|
||||||
#[arg(long = "config", short = 'c', global = true)]
|
|
||||||
config: Option<PathBuf>,
|
|
||||||
|
|
||||||
#[arg()]
|
#[arg()]
|
||||||
search: Option<String>,
|
search: Option<String>,
|
||||||
|
|
||||||
@@ -50,12 +41,6 @@ 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,
|
||||||
|
|
||||||
@@ -67,10 +52,6 @@ struct Command {
|
|||||||
enum Commands {
|
enum Commands {
|
||||||
Init(Shell),
|
Init(Shell),
|
||||||
Update(Update),
|
Update(Update),
|
||||||
Clone(CloneCommand),
|
|
||||||
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";
|
||||||
@@ -86,33 +67,19 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
)
|
)
|
||||||
.init();
|
.init();
|
||||||
|
|
||||||
let cli = Command::parse();
|
|
||||||
tracing::debug!("Starting cli");
|
|
||||||
|
|
||||||
let config_path = if let Some(path) = &cli.config {
|
|
||||||
path.clone()
|
|
||||||
} else {
|
|
||||||
let home =
|
let home =
|
||||||
std::env::var("HOME").context("HOME was not found, are you using a proper shell?")?;
|
std::env::var("HOME").context("HOME was not found, are you using a proper shell?")?;
|
||||||
let default_config_path = PathBuf::from(home).join(DEFAULT_CONFIG_PATH);
|
let default_config_path = PathBuf::from(home).join(DEFAULT_CONFIG_PATH);
|
||||||
std::env::var("GITNOW_CONFIG")
|
let config_path = std::env::var("GITNOW_CONFIG")
|
||||||
.map(PathBuf::from)
|
.map(PathBuf::from)
|
||||||
.unwrap_or(default_config_path)
|
.unwrap_or(default_config_path);
|
||||||
};
|
|
||||||
|
|
||||||
let config = Config::from_file(&config_path).await?;
|
let config = Config::from_file(&config_path).await?;
|
||||||
|
|
||||||
let app = app::App::new_static(config).await?;
|
let app = app::App::new_static(config).await?;
|
||||||
|
|
||||||
// When a chooser file is provided, it implies --no-shell behaviour:
|
let cli = Command::parse();
|
||||||
// the selected path is written to the file and no interactive shell is
|
tracing::debug!("Starting cli");
|
||||||
// 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 {
|
||||||
@@ -122,15 +89,6 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
Commands::Update(mut update) => {
|
Commands::Update(mut update) => {
|
||||||
update.execute(app).await?;
|
update.execute(app).await?;
|
||||||
}
|
}
|
||||||
Commands::Clone(mut clone) => {
|
|
||||||
clone.execute(app).await?;
|
|
||||||
}
|
|
||||||
Commands::Worktree(mut wt) => {
|
|
||||||
wt.execute(app, &chooser).await?;
|
|
||||||
}
|
|
||||||
Commands::Project(mut project) => {
|
|
||||||
project.execute(app, &chooser).await?;
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
None => {
|
None => {
|
||||||
RootCommand::new(app)
|
RootCommand::new(app)
|
||||||
@@ -138,10 +96,9 @@ 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,
|
||||||
!no_shell,
|
!cli.no_shell,
|
||||||
cli.force_refresh,
|
cli.force_refresh,
|
||||||
cli.force_cache_update,
|
cli.force_cache_update,
|
||||||
&chooser,
|
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,124 +0,0 @@
|
|||||||
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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,3 @@
|
|||||||
use std::path::Path;
|
|
||||||
|
|
||||||
use anyhow::Context;
|
use anyhow::Context;
|
||||||
|
|
||||||
use crate::{app::App, git_provider::Repository};
|
use crate::{app::App, git_provider::Repository};
|
||||||
@@ -22,14 +20,10 @@ impl Shell {
|
|||||||
.directory
|
.directory
|
||||||
.join(repository.to_rel_path());
|
.join(repository.to_rel_path());
|
||||||
|
|
||||||
self.spawn_shell_at(&project_path).await
|
if !project_path.exists() {
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn spawn_shell_at(&self, path: &Path) -> anyhow::Result<()> {
|
|
||||||
if !path.exists() {
|
|
||||||
anyhow::bail!(
|
anyhow::bail!(
|
||||||
"project path: {} does not exists, it is either a file, or hasn't been cloned",
|
"project path: {} does not exists, it is either a file, or hasn't been cloned",
|
||||||
path.display()
|
project_path.display()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,7 +31,7 @@ impl Shell {
|
|||||||
.context("failed to find SHELL variable, required for spawning embedded shells")?;
|
.context("failed to find SHELL variable, required for spawning embedded shells")?;
|
||||||
|
|
||||||
let mut shell_cmd = tokio::process::Command::new(shell);
|
let mut shell_cmd = tokio::process::Command::new(shell);
|
||||||
shell_cmd.current_dir(path);
|
shell_cmd.current_dir(project_path);
|
||||||
|
|
||||||
let mut process = shell_cmd.spawn().context("failed to spawn child session")?;
|
let mut process = shell_cmd.spawn().context("failed to spawn child session")?;
|
||||||
|
|
||||||
|
|||||||
@@ -1,146 +0,0 @@
|
|||||||
use std::collections::HashMap;
|
|
||||||
|
|
||||||
use anyhow::Context;
|
|
||||||
|
|
||||||
pub const DEFAULT_CLONE_COMMAND: &str = "git clone {{ ssh_url }} {{ path }}";
|
|
||||||
pub const DEFAULT_WORKTREE_CLONE_COMMAND: &str =
|
|
||||||
"git clone --bare {{ ssh_url }} {{ bare_path }}";
|
|
||||||
pub const DEFAULT_WORKTREE_ADD_COMMAND: &str =
|
|
||||||
"git -C {{ bare_path }} worktree add {{ worktree_path }} {{ branch }}";
|
|
||||||
pub const DEFAULT_LIST_BRANCHES_COMMAND: &str =
|
|
||||||
"git -C {{ bare_path }} branch --format=%(refname:short)";
|
|
||||||
|
|
||||||
pub async fn render_and_execute(
|
|
||||||
template: &str,
|
|
||||||
context: HashMap<&str, &str>,
|
|
||||||
) -> anyhow::Result<std::process::Output> {
|
|
||||||
let (program, args) = render_command_parts(template, &context)?;
|
|
||||||
|
|
||||||
tracing::debug!("executing: {} {}", program, args.join(" "));
|
|
||||||
|
|
||||||
let output = tokio::process::Command::new(&program)
|
|
||||||
.args(&args)
|
|
||||||
.output()
|
|
||||||
.await
|
|
||||||
.with_context(|| format!("failed to execute: {} {}", program, args.join(" ")))?;
|
|
||||||
|
|
||||||
Ok(output)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn render_command_parts(
|
|
||||||
template: &str,
|
|
||||||
context: &HashMap<&str, &str>,
|
|
||||||
) -> anyhow::Result<(String, Vec<String>)> {
|
|
||||||
let env = minijinja::Environment::new();
|
|
||||||
let rendered = env
|
|
||||||
.render_str(template, context)
|
|
||||||
.context("failed to render command template")?;
|
|
||||||
|
|
||||||
let parts =
|
|
||||||
shell_words::split(&rendered).context("failed to parse rendered command as shell words")?;
|
|
||||||
|
|
||||||
let (program, args) = parts
|
|
||||||
.split_first()
|
|
||||||
.ok_or_else(|| anyhow::anyhow!("command template rendered to empty string"))?;
|
|
||||||
|
|
||||||
Ok((program.clone(), args.to_vec()))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_render_clone_command() {
|
|
||||||
let context = HashMap::from([
|
|
||||||
("ssh_url", "ssh://git@github.com/owner/repo.git"),
|
|
||||||
("path", "/home/user/git/github.com/owner/repo"),
|
|
||||||
]);
|
|
||||||
|
|
||||||
let (program, args) = render_command_parts(DEFAULT_CLONE_COMMAND, &context).unwrap();
|
|
||||||
assert_eq!(program, "git");
|
|
||||||
assert_eq!(
|
|
||||||
args,
|
|
||||||
vec![
|
|
||||||
"clone",
|
|
||||||
"ssh://git@github.com/owner/repo.git",
|
|
||||||
"/home/user/git/github.com/owner/repo"
|
|
||||||
]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_render_jj_clone_command() {
|
|
||||||
let template = "jj git clone {{ ssh_url }} {{ path }}";
|
|
||||||
let context = HashMap::from([
|
|
||||||
("ssh_url", "ssh://git@github.com/owner/repo.git"),
|
|
||||||
("path", "/home/user/git/github.com/owner/repo"),
|
|
||||||
]);
|
|
||||||
|
|
||||||
let (program, args) = render_command_parts(template, &context).unwrap();
|
|
||||||
assert_eq!(program, "jj");
|
|
||||||
assert_eq!(
|
|
||||||
args,
|
|
||||||
vec![
|
|
||||||
"git",
|
|
||||||
"clone",
|
|
||||||
"ssh://git@github.com/owner/repo.git",
|
|
||||||
"/home/user/git/github.com/owner/repo"
|
|
||||||
]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_render_worktree_clone_command() {
|
|
||||||
let context = HashMap::from([
|
|
||||||
("ssh_url", "ssh://git@github.com/owner/repo.git"),
|
|
||||||
(
|
|
||||||
"bare_path",
|
|
||||||
"/home/user/git/github.com/owner/repo/.bare",
|
|
||||||
),
|
|
||||||
]);
|
|
||||||
|
|
||||||
let (program, args) =
|
|
||||||
render_command_parts(DEFAULT_WORKTREE_CLONE_COMMAND, &context).unwrap();
|
|
||||||
assert_eq!(program, "git");
|
|
||||||
assert_eq!(
|
|
||||||
args,
|
|
||||||
vec![
|
|
||||||
"clone",
|
|
||||||
"--bare",
|
|
||||||
"ssh://git@github.com/owner/repo.git",
|
|
||||||
"/home/user/git/github.com/owner/repo/.bare"
|
|
||||||
]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_render_worktree_add_command() {
|
|
||||||
let context = HashMap::from([
|
|
||||||
(
|
|
||||||
"bare_path",
|
|
||||||
"/home/user/git/github.com/owner/repo/.bare",
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"worktree_path",
|
|
||||||
"/home/user/git/github.com/owner/repo/feature-x",
|
|
||||||
),
|
|
||||||
("branch", "feature/x"),
|
|
||||||
]);
|
|
||||||
|
|
||||||
let (program, args) =
|
|
||||||
render_command_parts(DEFAULT_WORKTREE_ADD_COMMAND, &context).unwrap();
|
|
||||||
assert_eq!(program, "git");
|
|
||||||
assert_eq!(
|
|
||||||
args,
|
|
||||||
vec![
|
|
||||||
"-C",
|
|
||||||
"/home/user/git/github.com/owner/repo/.bare",
|
|
||||||
"worktree",
|
|
||||||
"add",
|
|
||||||
"/home/user/git/github.com/owner/repo/feature-x",
|
|
||||||
"feature/x"
|
|
||||||
]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,182 +0,0 @@
|
|||||||
use std::collections::BTreeSet;
|
|
||||||
use std::collections::HashMap;
|
|
||||||
use std::path::{Path, PathBuf};
|
|
||||||
|
|
||||||
use crate::{app::App, git_provider::Repository, template_command};
|
|
||||||
|
|
||||||
pub struct Worktree {
|
|
||||||
app: &'static App,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Worktree {
|
|
||||||
pub fn new(app: &'static App) -> Self {
|
|
||||||
Self { app }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns the project path and bare path for a repository in worktree mode.
|
|
||||||
/// Layout: <project_path>/.bare/ for the bare clone,
|
|
||||||
/// <project_path>/<branch>/ for each worktree.
|
|
||||||
pub fn paths(&self, repository: &Repository) -> (PathBuf, PathBuf) {
|
|
||||||
let project_path = self
|
|
||||||
.app
|
|
||||||
.config
|
|
||||||
.settings
|
|
||||||
.projects
|
|
||||||
.directory
|
|
||||||
.join(repository.to_rel_path());
|
|
||||||
let bare_path = project_path.join(".bare");
|
|
||||||
(project_path, bare_path)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Ensures a bare clone exists at `<project_path>/.bare/`.
|
|
||||||
/// Skips if already present.
|
|
||||||
pub async fn ensure_bare_clone(
|
|
||||||
&self,
|
|
||||||
repository: &Repository,
|
|
||||||
bare_path: &Path,
|
|
||||||
) -> anyhow::Result<()> {
|
|
||||||
if bare_path.exists() {
|
|
||||||
tracing::info!("bare clone already exists at {}", bare_path.display());
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
let template = self
|
|
||||||
.app
|
|
||||||
.config
|
|
||||||
.settings
|
|
||||||
.worktree
|
|
||||||
.as_ref()
|
|
||||||
.and_then(|w| w.clone_command.as_deref())
|
|
||||||
.unwrap_or(template_command::DEFAULT_WORKTREE_CLONE_COMMAND);
|
|
||||||
|
|
||||||
let bare_path_str = bare_path.display().to_string();
|
|
||||||
let context = HashMap::from([
|
|
||||||
("ssh_url", repository.ssh_url.as_str()),
|
|
||||||
("bare_path", bare_path_str.as_str()),
|
|
||||||
]);
|
|
||||||
|
|
||||||
tracing::info!(
|
|
||||||
"bare-cloning {} into {}",
|
|
||||||
repository.ssh_url.as_str(),
|
|
||||||
bare_path.display()
|
|
||||||
);
|
|
||||||
|
|
||||||
let output = template_command::render_and_execute(template, context).await?;
|
|
||||||
|
|
||||||
if !output.status.success() {
|
|
||||||
let stderr = std::str::from_utf8(&output.stderr).unwrap_or_default();
|
|
||||||
anyhow::bail!("failed to bare-clone: {}", stderr);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn list_branches(&self, bare_path: &Path) -> anyhow::Result<Vec<String>> {
|
|
||||||
let template = self
|
|
||||||
.app
|
|
||||||
.config
|
|
||||||
.settings
|
|
||||||
.worktree
|
|
||||||
.as_ref()
|
|
||||||
.and_then(|w| w.list_branches_command.as_deref())
|
|
||||||
.unwrap_or(template_command::DEFAULT_LIST_BRANCHES_COMMAND);
|
|
||||||
|
|
||||||
let bare_path_str = bare_path.display().to_string();
|
|
||||||
let context = HashMap::from([("bare_path", bare_path_str.as_str())]);
|
|
||||||
|
|
||||||
let output = template_command::render_and_execute(template, context).await?;
|
|
||||||
|
|
||||||
if !output.status.success() {
|
|
||||||
let stderr = std::str::from_utf8(&output.stderr).unwrap_or_default();
|
|
||||||
anyhow::bail!("failed to list branches: {}", stderr);
|
|
||||||
}
|
|
||||||
|
|
||||||
let stdout = std::str::from_utf8(&output.stdout)?;
|
|
||||||
let branches: Vec<String> = stdout
|
|
||||||
.lines()
|
|
||||||
.map(|l| l.trim())
|
|
||||||
.filter(|l| !l.is_empty())
|
|
||||||
.filter(|l| !l.contains("HEAD"))
|
|
||||||
// Strip origin/ prefix if present (for non-bare repos or custom commands)
|
|
||||||
.map(|l| l.strip_prefix("origin/").unwrap_or(l).to_string())
|
|
||||||
.collect::<BTreeSet<_>>()
|
|
||||||
.into_iter()
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
Ok(branches)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn add_worktree(
|
|
||||||
&self,
|
|
||||||
bare_path: &Path,
|
|
||||||
worktree_path: &Path,
|
|
||||||
branch: &str,
|
|
||||||
) -> anyhow::Result<()> {
|
|
||||||
let template = self
|
|
||||||
.app
|
|
||||||
.config
|
|
||||||
.settings
|
|
||||||
.worktree
|
|
||||||
.as_ref()
|
|
||||||
.and_then(|w| w.add_command.as_deref())
|
|
||||||
.unwrap_or(template_command::DEFAULT_WORKTREE_ADD_COMMAND);
|
|
||||||
|
|
||||||
let bare_path_str = bare_path.display().to_string();
|
|
||||||
let worktree_path_str = worktree_path.display().to_string();
|
|
||||||
let context = HashMap::from([
|
|
||||||
("bare_path", bare_path_str.as_str()),
|
|
||||||
("worktree_path", worktree_path_str.as_str()),
|
|
||||||
("branch", branch),
|
|
||||||
]);
|
|
||||||
|
|
||||||
tracing::info!(
|
|
||||||
"creating worktree for branch '{}' at {}",
|
|
||||||
branch,
|
|
||||||
worktree_path.display()
|
|
||||||
);
|
|
||||||
|
|
||||||
let output = template_command::render_and_execute(template, context).await?;
|
|
||||||
|
|
||||||
if !output.status.success() {
|
|
||||||
let stderr = std::str::from_utf8(&output.stderr).unwrap_or_default();
|
|
||||||
anyhow::bail!("failed to create worktree: {}", stderr);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn sanitize_branch_name(branch: &str) -> String {
|
|
||||||
let sanitized = branch.replace('/', "-");
|
|
||||||
if let Some(stripped) = sanitized.strip_prefix('.') {
|
|
||||||
format!("_{stripped}")
|
|
||||||
} else {
|
|
||||||
sanitized
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub trait WorktreeApp {
|
|
||||||
fn worktree(&self) -> Worktree;
|
|
||||||
}
|
|
||||||
|
|
||||||
impl WorktreeApp for &'static App {
|
|
||||||
fn worktree(&self) -> Worktree {
|
|
||||||
Worktree::new(self)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_sanitize_branch_name() {
|
|
||||||
assert_eq!(sanitize_branch_name("feature/login"), "feature-login");
|
|
||||||
assert_eq!(sanitize_branch_name("main"), "main");
|
|
||||||
assert_eq!(
|
|
||||||
sanitize_branch_name("fix/nested/path"),
|
|
||||||
"fix-nested-path"
|
|
||||||
);
|
|
||||||
assert_eq!(sanitize_branch_name(".hidden"), "_hidden");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
# Example: git with worktree support
|
|
||||||
#
|
|
||||||
# Uses bare repositories for worktrees. Directory layout:
|
|
||||||
#
|
|
||||||
# ~/git/github.com/owner/repo/
|
|
||||||
# ├── .bare/ # bare clone
|
|
||||||
# ├── main/ # worktree
|
|
||||||
# └── feature-x/ # worktree
|
|
||||||
#
|
|
||||||
# Usage:
|
|
||||||
# gitnow worktree # pick repo, then pick branch
|
|
||||||
# gitnow worktree myrepo -b main
|
|
||||||
|
|
||||||
[settings]
|
|
||||||
# Normal clone (used by `gitnow` without worktree subcommand)
|
|
||||||
# clone_command = "git clone {{ ssh_url }} {{ path }}"
|
|
||||||
|
|
||||||
[settings.worktree]
|
|
||||||
# All of these are the defaults — shown here for reference.
|
|
||||||
# You only need [settings.worktree] if you want to override them.
|
|
||||||
|
|
||||||
# Bare clone for worktree repos
|
|
||||||
clone_command = "git clone --bare {{ ssh_url }} {{ bare_path }}"
|
|
||||||
|
|
||||||
# Create a worktree from the bare repo
|
|
||||||
add_command = "git -C {{ bare_path }} worktree add {{ worktree_path }} {{ branch }}"
|
|
||||||
|
|
||||||
# List branches in the bare repo
|
|
||||||
list_branches_command = "git -C {{ bare_path }} branch --format=%(refname:short)"
|
|
||||||
|
|
||||||
[[providers.github]]
|
|
||||||
current_user = "your-user"
|
|
||||||
access_token = { env = "GITHUB_ACCESS_TOKEN" }
|
|
||||||
organisations = ["your-org"]
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
# Example: jj (Jujutsu) with workspace support
|
|
||||||
#
|
|
||||||
# Uses jj for both normal clones and worktrees/workspaces.
|
|
||||||
#
|
|
||||||
# Normal clone (`gitnow`):
|
|
||||||
# ~/git/github.com/owner/repo/ # jj git clone
|
|
||||||
#
|
|
||||||
# Worktree/workspace (`gitnow worktree`):
|
|
||||||
# ~/git/github.com/owner/repo/
|
|
||||||
# ├── .bare/ # jj git clone (used as the main repo)
|
|
||||||
# ├── main/ # jj workspace
|
|
||||||
# └── feature-x/ # jj workspace
|
|
||||||
#
|
|
||||||
# Usage:
|
|
||||||
# gitnow # clone with jj, enter repo
|
|
||||||
# gitnow worktree # pick repo, then pick branch/workspace
|
|
||||||
# gitnow worktree myrepo -b main
|
|
||||||
|
|
||||||
[settings]
|
|
||||||
# Use jj for normal clones
|
|
||||||
clone_command = "jj git clone {{ ssh_url }} {{ path }}"
|
|
||||||
|
|
||||||
# Runs after a project is fetched for the first time
|
|
||||||
post_clone_command = "jj git fetch --all-remotes"
|
|
||||||
|
|
||||||
# Runs when jumping to an already-cloned project
|
|
||||||
post_update_command = "jj git fetch --all-remotes"
|
|
||||||
|
|
||||||
[settings.worktree]
|
|
||||||
# Clone the repo for worktree use
|
|
||||||
clone_command = "jj git clone {{ ssh_url }} {{ bare_path }}"
|
|
||||||
|
|
||||||
# Create a new jj workspace for the selected branch
|
|
||||||
add_command = "jj -R {{ bare_path }} workspace add --name {{ branch }} {{ worktree_path }}"
|
|
||||||
|
|
||||||
# List bookmarks from the jj repo (one name per line)
|
|
||||||
list_branches_command = "jj -R {{ bare_path }} bookmark list -T 'name ++ \"\\n\"'"
|
|
||||||
|
|
||||||
[[providers.github]]
|
|
||||||
current_user = "your-user"
|
|
||||||
access_token = { env = "GITHUB_ACCESS_TOKEN" }
|
|
||||||
organisations = ["your-org"]
|
|
||||||
27
mise.toml
27
mise.toml
@@ -1,27 +0,0 @@
|
|||||||
[tools]
|
|
||||||
rust = "1.93"
|
|
||||||
goreleaser = "latest"
|
|
||||||
zig = "latest"
|
|
||||||
"cargo:cargo-zigbuild" = "latest"
|
|
||||||
|
|
||||||
[tasks.install]
|
|
||||||
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)"
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
# 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 -->
|
|
||||||
Reference in New Issue
Block a user