Compare commits
No commits in common. "main" and "v0.1.0" have entirely different histories.
1
.gitignore
vendored
1
.gitignore
vendored
@ -1,3 +1,2 @@
|
|||||||
target/
|
target/
|
||||||
.cuddle/
|
.cuddle/
|
||||||
.DS_Store
|
|
||||||
|
85
CHANGELOG.md
85
CHANGELOG.md
@ -6,91 +6,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
## [0.2.3] - 2024-09-26
|
|
||||||
|
|
||||||
### Added
|
|
||||||
- add update command
|
|
||||||
- only do clone if not exists
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- *(deps)* update rust crate async-trait to v0.1.83
|
|
||||||
- *(deps)* update rust crate octocrab to 0.40.0
|
|
||||||
|
|
||||||
## [0.2.2] - 2024-09-23
|
|
||||||
|
|
||||||
### Other
|
|
||||||
- add docs
|
|
||||||
|
|
||||||
main@origin
|
|
||||||
|
|
||||||
- add license
|
|
||||||
- update to gitea-client
|
|
||||||
- add publish
|
|
||||||
- *(release)* 0.2.1
|
|
||||||
|
|
||||||
## [0.2.1] - 2024-09-23
|
|
||||||
|
|
||||||
### Added
|
|
||||||
- use termwiz as backend as that enables a ptty, which can be cleaned up nicely
|
|
||||||
- add errout for interactive for script support and atty for clean output
|
|
||||||
- add clone spinner
|
|
||||||
- add spinner around download
|
|
||||||
- spawn a subshell for session
|
|
||||||
- implement git clone
|
|
||||||
- include vhs demo
|
|
||||||
- add interactive search
|
|
||||||
- implement naive fuzzy matcher
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- *(deps)* update tokio-prost monorepo to v0.13.3
|
|
||||||
- *(deps)* update rust crate bytes to v1.7.2
|
|
||||||
|
|
||||||
### Other
|
|
||||||
- update gif to include spinner
|
|
||||||
- clean up ui
|
|
||||||
- build in cuddle instead of vhs
|
|
||||||
- build first then run
|
|
||||||
- clear screen after build
|
|
||||||
- fix warnings
|
|
||||||
- update theme for vhs
|
|
||||||
- *(deps)* update rust crate clap to v4.5.18
|
|
||||||
- *(deps)* update rust crate pretty_assertions to v1.4.1
|
|
||||||
- refactor fuzzy match into own function
|
|
||||||
- cleanup warnings
|
|
||||||
- move fuzzy search out of command
|
|
||||||
- refactor/matcher move to a separate file
|
|
||||||
|
|
||||||
- move fuzzy search out of command
|
|
||||||
- Actually add fuzzy matcher
|
|
||||||
|
|
||||||
- extract matcher
|
|
||||||
- update dependencies
|
|
||||||
- *(deps)* update rust crate anyhow to v1.0.89
|
|
||||||
|
|
||||||
## [0.2.0] - 2024-09-14
|
|
||||||
|
|
||||||
### Added
|
|
||||||
- add cache get
|
|
||||||
- send out wait
|
|
||||||
- add cache
|
|
||||||
- add settings config
|
|
||||||
- add github fetch prs refactoring
|
|
||||||
- gitea able to pull repositories
|
|
||||||
- add config
|
|
||||||
|
|
||||||
### Docs
|
|
||||||
- add readme
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- don't have to use user for basic auth
|
|
||||||
|
|
||||||
### Other
|
|
||||||
- removed unused code
|
|
||||||
- move projects list into separate file
|
|
||||||
- separate files
|
|
||||||
- move config out
|
|
||||||
- remove unused libraries
|
|
||||||
|
|
||||||
## [0.1.0] - 2024-09-12
|
## [0.1.0] - 2024-09-12
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
2270
Cargo.lock
generated
2270
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@ -3,13 +3,15 @@ members = ["crates/*"]
|
|||||||
resolver = "2"
|
resolver = "2"
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "0.2.3"
|
version = "0.1.0"
|
||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
|
gitnow = { path = "crates/gitnow" }
|
||||||
|
|
||||||
anyhow = { version = "1" }
|
anyhow = { version = "1" }
|
||||||
tokio = { version = "1", features = ["full"] }
|
tokio = { version = "1", features = ["full"] }
|
||||||
tracing = { version = "0.1", features = ["log"] }
|
tracing = { version = "0.1", features = ["log"] }
|
||||||
tracing-subscriber = { version = "0.3.18", features = ["env-filter"] }
|
tracing-subscriber = { version = "0.3.18" }
|
||||||
clap = { version = "4", features = ["derive", "env"] }
|
clap = { version = "4", features = ["derive", "env"] }
|
||||||
dotenv = { version = "0.15" }
|
dotenv = { version = "0.15" }
|
||||||
|
axum = { version = "0.7" }
|
||||||
|
34
README.md
34
README.md
@ -1,38 +1,4 @@
|
|||||||
# Git Now
|
# Git Now
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
![example gif](./assets/gifs/example.gif)
|
|
||||||
|
|
||||||
## Installation
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cargo (b)install gitnow
|
|
||||||
|
|
||||||
# You can either use gitnow directly (and use spawned shell sessions)
|
|
||||||
gitnow
|
|
||||||
|
|
||||||
# Or install gitnow scripts (in your .bashrc, .zshrc) this will use native shell commands to move you around
|
|
||||||
eval $(gitnow init zsh)
|
|
||||||
git-now # Long
|
|
||||||
gn # Short alias
|
|
||||||
```
|
|
||||||
|
|
||||||
## Reasoning
|
|
||||||
|
|
||||||
How many steps do you normally do to download a project?
|
|
||||||
|
|
||||||
1. Navigate to github.com
|
|
||||||
2. Search in your org for the project
|
|
||||||
3. Find the clone url
|
|
||||||
4. Navigate to your local github repositories path
|
|
||||||
5. Git clone `<project>`
|
|
||||||
6. Enter new project directory
|
|
||||||
|
|
||||||
A power user can of course use `gh repo clone` to skip a few steps.
|
|
||||||
|
|
||||||
With gitnow
|
|
||||||
|
|
||||||
1. `git now`
|
|
||||||
2. Enter parts of the project name and press enter
|
|
||||||
3. Your project is automatically downloaded if it doesn't exist in an opinionated path dir, and move you there.
|
|
||||||
|
Binary file not shown.
Before Width: | Height: | Size: 452 KiB |
10
buf.gen.yaml
10
buf.gen.yaml
@ -1,10 +0,0 @@
|
|||||||
version: v2
|
|
||||||
managed:
|
|
||||||
enabled: true
|
|
||||||
plugins:
|
|
||||||
# dependencies
|
|
||||||
- remote: buf.build/community/neoeinstein-prost
|
|
||||||
out: crates/gitnow/src/gen
|
|
||||||
|
|
||||||
inputs:
|
|
||||||
- directory: crates/gitnow/proto
|
|
4
buf.yaml
4
buf.yaml
@ -1,4 +0,0 @@
|
|||||||
version: v2
|
|
||||||
modules:
|
|
||||||
- path: proto
|
|
||||||
name: buf.build/noschemaplz/gitnow
|
|
1
crates/gitnow/.gitignore
vendored
Normal file
1
crates/gitnow/.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
/target
|
@ -1,15 +1,8 @@
|
|||||||
[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."
|
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
readme = "../../README.md"
|
|
||||||
repository = "https://github.com/kjuulh/gitnow"
|
|
||||||
homepage = "https://gitnow-client.prod.kjuulh.app"
|
|
||||||
license = "MIT"
|
|
||||||
|
|
||||||
version.workspace = true
|
version.workspace = true
|
||||||
publish = true
|
|
||||||
|
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
anyhow.workspace = true
|
anyhow.workspace = true
|
||||||
@ -18,27 +11,9 @@ tracing.workspace = true
|
|||||||
tracing-subscriber.workspace = true
|
tracing-subscriber.workspace = true
|
||||||
clap.workspace = true
|
clap.workspace = true
|
||||||
dotenv.workspace = true
|
dotenv.workspace = true
|
||||||
|
axum.workspace = true
|
||||||
|
|
||||||
serde = { version = "1.0.197", features = ["derive"] }
|
serde = { version = "1.0.197", features = ["derive"] }
|
||||||
|
sqlx = { version = "0.7.3", features = ["runtime-tokio", "tls-rustls", "postgres", "uuid", "time"] }
|
||||||
uuid = { version = "1.7.0", features = ["v4"] }
|
uuid = { version = "1.7.0", features = ["v4"] }
|
||||||
async-trait = "0.1.82"
|
tower-http = { version = "0.5.2", features = ["cors", "trace"] }
|
||||||
toml = "0.8.19"
|
|
||||||
|
|
||||||
gitea-client = { version = "1.22.1" }
|
|
||||||
url = "2.5.2"
|
|
||||||
octocrab = "0.41.0"
|
|
||||||
dirs = "5.0.1"
|
|
||||||
prost = "0.13.2"
|
|
||||||
prost-types = "0.13.2"
|
|
||||||
bytes = "1.7.1"
|
|
||||||
nucleo-matcher = "0.3.1"
|
|
||||||
ratatui = { version = "0.29.0", features = ["termwiz"] }
|
|
||||||
crossterm = { version = "0.28.0", features = ["event-stream"] }
|
|
||||||
futures = "0.3.30"
|
|
||||||
termwiz = "0.22.0"
|
|
||||||
|
|
||||||
[dev-dependencies]
|
|
||||||
pretty_assertions = "1.4.0"
|
|
||||||
|
|
||||||
[features]
|
|
||||||
example = []
|
|
||||||
|
@ -1,22 +0,0 @@
|
|||||||
function git-now {
|
|
||||||
# Run an update in the background
|
|
||||||
(
|
|
||||||
nohup gitnow update > /dev/null 2>&1 &
|
|
||||||
)
|
|
||||||
|
|
||||||
# Find the repository of choice
|
|
||||||
choice=$(gitnow "$@" --no-shell)
|
|
||||||
if [[ $? -ne 0 ]]; then
|
|
||||||
return $?
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Enter local repository path
|
|
||||||
cd "$(echo "$choice" | tail --lines 1)"
|
|
||||||
}
|
|
||||||
|
|
||||||
function gn {
|
|
||||||
git-now "$@"
|
|
||||||
if [[ $? -ne 0 ]]; then
|
|
||||||
return $?
|
|
||||||
fi
|
|
||||||
}
|
|
@ -1,14 +0,0 @@
|
|||||||
syntax = "proto3";
|
|
||||||
|
|
||||||
package gitnow.v1;
|
|
||||||
|
|
||||||
message Repositories {
|
|
||||||
repeated Repository repositories = 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
message Repository {
|
|
||||||
string provider = 1;
|
|
||||||
string owner = 2;
|
|
||||||
string repo_name= 3;
|
|
||||||
string ssh_url = 4;
|
|
||||||
}
|
|
@ -1,12 +0,0 @@
|
|||||||
use crate::config::Config;
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub struct App {
|
|
||||||
pub config: Config,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl App {
|
|
||||||
pub async fn new_static(config: Config) -> anyhow::Result<&'static App> {
|
|
||||||
Ok(Box::leak(Box::new(App { config })))
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,126 +0,0 @@
|
|||||||
use std::path::PathBuf;
|
|
||||||
|
|
||||||
use anyhow::Context;
|
|
||||||
use tokio::io::AsyncWriteExt;
|
|
||||||
|
|
||||||
use crate::{app::App, cache_codec::CacheCodecApp, config::Config, git_provider::Repository};
|
|
||||||
|
|
||||||
pub struct Cache {
|
|
||||||
app: &'static App,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Cache {
|
|
||||||
pub fn new(app: &'static App) -> Self {
|
|
||||||
Self { app }
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn update(&self, repositories: &[Repository]) -> anyhow::Result<()> {
|
|
||||||
tracing::debug!(repository_len = repositories.len(), "storing repositories");
|
|
||||||
|
|
||||||
let location = self.app.config.get_cache_file_location()?;
|
|
||||||
tracing::trace!("found cache location: {}", location.display());
|
|
||||||
|
|
||||||
if let Some(parent) = location.parent() {
|
|
||||||
tokio::fs::create_dir_all(parent).await?;
|
|
||||||
}
|
|
||||||
|
|
||||||
let cache_content = self
|
|
||||||
.app
|
|
||||||
.cache_codec()
|
|
||||||
.serialize_repositories(repositories)?;
|
|
||||||
|
|
||||||
let mut cache_file = tokio::fs::File::create(location)
|
|
||||||
.await
|
|
||||||
.context("failed to create cache file")?;
|
|
||||||
cache_file
|
|
||||||
.write_all(&cache_content)
|
|
||||||
.await
|
|
||||||
.context("failed to write cache content to file")?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn get(&self) -> anyhow::Result<Option<Vec<Repository>>> {
|
|
||||||
tracing::debug!("fetching repositories");
|
|
||||||
|
|
||||||
let location = self.app.config.get_cache_file_location()?;
|
|
||||||
if !location.exists() {
|
|
||||||
tracing::debug!(
|
|
||||||
location = location.display().to_string(),
|
|
||||||
"cache doesn't exist"
|
|
||||||
);
|
|
||||||
return Ok(None);
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(cache_duration) = self.app.config.settings.cache.duration.get_duration() {
|
|
||||||
let metadata = tokio::fs::metadata(&location).await?;
|
|
||||||
|
|
||||||
if let Ok(file_modified_last) = metadata
|
|
||||||
.modified()
|
|
||||||
.context("failed to get modified date")
|
|
||||||
.inspect_err(|e| {
|
|
||||||
tracing::warn!(
|
|
||||||
"could not get valid metadata from file, cache will be reused: {}",
|
|
||||||
e
|
|
||||||
);
|
|
||||||
})
|
|
||||||
.and_then(|m| {
|
|
||||||
m.elapsed()
|
|
||||||
.context("failed to get elapsed from file")
|
|
||||||
.inspect_err(|e| tracing::warn!("failed to get elapsed from system: {}", e))
|
|
||||||
})
|
|
||||||
{
|
|
||||||
tracing::trace!(
|
|
||||||
cache = file_modified_last.as_secs(),
|
|
||||||
expiry = cache_duration.as_secs(),
|
|
||||||
"checking if cache is valid"
|
|
||||||
);
|
|
||||||
if file_modified_last > cache_duration {
|
|
||||||
tracing::debug!("cache has expired");
|
|
||||||
return Ok(None);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let file = tokio::fs::read(&location).await?;
|
|
||||||
if file.is_empty() {
|
|
||||||
tracing::debug!("cache file appears to be empty");
|
|
||||||
return Ok(None);
|
|
||||||
}
|
|
||||||
|
|
||||||
let repos = match self.app.cache_codec().deserialize_repositories(file) {
|
|
||||||
Ok(repos) => repos,
|
|
||||||
Err(e) => {
|
|
||||||
tracing::warn!(error = e.to_string(), "failed to deserialize repositories");
|
|
||||||
return Ok(None);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok(Some(repos))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub trait CacheApp {
|
|
||||||
fn cache(&self) -> Cache;
|
|
||||||
}
|
|
||||||
|
|
||||||
impl CacheApp for &'static App {
|
|
||||||
fn cache(&self) -> Cache {
|
|
||||||
Cache::new(self)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub trait CacheConfig {
|
|
||||||
fn get_cache_location(&self) -> anyhow::Result<PathBuf>;
|
|
||||||
fn get_cache_file_location(&self) -> anyhow::Result<PathBuf>;
|
|
||||||
}
|
|
||||||
|
|
||||||
impl CacheConfig for Config {
|
|
||||||
fn get_cache_location(&self) -> anyhow::Result<PathBuf> {
|
|
||||||
Ok(self.settings.cache.location.clone().into())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn get_cache_file_location(&self) -> anyhow::Result<PathBuf> {
|
|
||||||
Ok(self.get_cache_location()?.join("cache.proto"))
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,61 +0,0 @@
|
|||||||
use std::io::Cursor;
|
|
||||||
|
|
||||||
use anyhow::Context;
|
|
||||||
use prost::Message;
|
|
||||||
|
|
||||||
use crate::{app::App, git_provider::Repository};
|
|
||||||
|
|
||||||
mod proto_codec {
|
|
||||||
include!("gen/gitnow.v1.rs");
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct CacheCodec {}
|
|
||||||
|
|
||||||
impl CacheCodec {
|
|
||||||
pub fn new() -> Self {
|
|
||||||
Self {}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn serialize_repositories(&self, repositories: &[Repository]) -> anyhow::Result<Vec<u8>> {
|
|
||||||
let mut codec_repos = proto_codec::Repositories::default();
|
|
||||||
|
|
||||||
for repo in repositories.iter().cloned() {
|
|
||||||
codec_repos.repositories.push(proto_codec::Repository {
|
|
||||||
provider: repo.provider,
|
|
||||||
owner: repo.owner,
|
|
||||||
repo_name: repo.repo_name,
|
|
||||||
ssh_url: repo.ssh_url,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(codec_repos.encode_to_vec())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn deserialize_repositories(&self, content: Vec<u8>) -> anyhow::Result<Vec<Repository>> {
|
|
||||||
let codex_repos = proto_codec::Repositories::decode(&mut Cursor::new(content))
|
|
||||||
.context("failed to decode protobuf repositories")?;
|
|
||||||
|
|
||||||
let mut repos = Vec::new();
|
|
||||||
|
|
||||||
for codec_repo in codex_repos.repositories {
|
|
||||||
repos.push(Repository {
|
|
||||||
provider: codec_repo.provider,
|
|
||||||
owner: codec_repo.owner,
|
|
||||||
repo_name: codec_repo.repo_name,
|
|
||||||
ssh_url: codec_repo.ssh_url,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(repos)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub trait CacheCodecApp {
|
|
||||||
fn cache_codec(&self) -> CacheCodec;
|
|
||||||
}
|
|
||||||
|
|
||||||
impl CacheCodecApp for &'static App {
|
|
||||||
fn cache_codec(&self) -> CacheCodec {
|
|
||||||
CacheCodec::new()
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,3 +0,0 @@
|
|||||||
pub mod root;
|
|
||||||
pub mod shell;
|
|
||||||
pub mod update;
|
|
@ -1,179 +0,0 @@
|
|||||||
use std::{collections::BTreeMap, io::IsTerminal};
|
|
||||||
|
|
||||||
use crate::{
|
|
||||||
app::App,
|
|
||||||
cache::CacheApp,
|
|
||||||
components::inline_command::InlineCommand,
|
|
||||||
fuzzy_matcher::{FuzzyMatcher, FuzzyMatcherApp},
|
|
||||||
git_clone::GitCloneApp,
|
|
||||||
git_provider::Repository,
|
|
||||||
interactive::InteractiveApp,
|
|
||||||
projects_list::ProjectsListApp,
|
|
||||||
shell::ShellApp,
|
|
||||||
};
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct RootCommand {
|
|
||||||
app: &'static App,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl RootCommand {
|
|
||||||
pub fn new(app: &'static App) -> Self {
|
|
||||||
Self { app }
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn execute(
|
|
||||||
&mut self,
|
|
||||||
search: Option<impl Into<String>>,
|
|
||||||
cache: bool,
|
|
||||||
clone: bool,
|
|
||||||
shell: bool,
|
|
||||||
force_refresh: bool,
|
|
||||||
force_cache_update: bool,
|
|
||||||
) -> anyhow::Result<()> {
|
|
||||||
tracing::debug!("executing");
|
|
||||||
|
|
||||||
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...");
|
|
||||||
let repositories = self.app.projects_list().get_projects().await?;
|
|
||||||
|
|
||||||
self.app.cache().update(&repositories).await?;
|
|
||||||
|
|
||||||
repositories
|
|
||||||
};
|
|
||||||
|
|
||||||
let repo = match search {
|
|
||||||
Some(needle) => {
|
|
||||||
let matched_repos = self
|
|
||||||
.app
|
|
||||||
.fuzzy_matcher()
|
|
||||||
.match_repositories(&needle.into(), &repositories);
|
|
||||||
|
|
||||||
let repo = matched_repos
|
|
||||||
.first()
|
|
||||||
.ok_or(anyhow::anyhow!("failed to find repository"))?;
|
|
||||||
tracing::debug!("selected repo: {}", repo.to_rel_path().display());
|
|
||||||
|
|
||||||
repo.to_owned()
|
|
||||||
}
|
|
||||||
None => {
|
|
||||||
let repo = self
|
|
||||||
.app
|
|
||||||
.interactive()
|
|
||||||
.interactive_search(&repositories)?
|
|
||||||
.ok_or(anyhow::anyhow!("failed to find a repository"))?;
|
|
||||||
|
|
||||||
tracing::debug!("selected repo: {}", repo.to_rel_path().display());
|
|
||||||
|
|
||||||
repo
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let project_path = self
|
|
||||||
.app
|
|
||||||
.config
|
|
||||||
.settings
|
|
||||||
.projects
|
|
||||||
.directory
|
|
||||||
.join(repo.to_rel_path());
|
|
||||||
if !project_path.exists() {
|
|
||||||
if clone {
|
|
||||||
let git_clone = self.app.git_clone();
|
|
||||||
|
|
||||||
if std::io::stdout().is_terminal() && shell {
|
|
||||||
let mut wrap_cmd =
|
|
||||||
InlineCommand::new(format!("cloning: {}", repo.to_rel_path().display()));
|
|
||||||
let repo = repo.clone();
|
|
||||||
wrap_cmd
|
|
||||||
.execute(move || async move {
|
|
||||||
git_clone.clone_repo(&repo, force_refresh).await?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
})
|
|
||||||
.await?;
|
|
||||||
} else {
|
|
||||||
eprintln!("cloning repository...");
|
|
||||||
git_clone.clone_repo(&repo, force_refresh).await?;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
tracing::info!("skipping clone for repo: {}", &repo.to_rel_path().display());
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
tracing::info!("repository already exists");
|
|
||||||
}
|
|
||||||
|
|
||||||
if shell {
|
|
||||||
self.app.shell().spawn_shell(&repo).await?;
|
|
||||||
} else {
|
|
||||||
tracing::info!("skipping shell for repo: {}", &repo.to_rel_path().display());
|
|
||||||
println!(
|
|
||||||
"{}",
|
|
||||||
self.app
|
|
||||||
.config
|
|
||||||
.settings
|
|
||||||
.projects
|
|
||||||
.directory
|
|
||||||
.join(repo.to_rel_path())
|
|
||||||
.display()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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 {
|
|
||||||
fn match_repositories(&self, pattern: &str, repositories: &[Repository]) -> Vec<Repository>;
|
|
||||||
}
|
|
||||||
|
|
||||||
impl RepositoryMatcher for FuzzyMatcher {
|
|
||||||
fn match_repositories(&self, pattern: &str, repositories: &[Repository]) -> Vec<Repository> {
|
|
||||||
let haystack = repositories
|
|
||||||
.iter()
|
|
||||||
.map(|r| (r.to_rel_path().display().to_string(), r))
|
|
||||||
.collect::<BTreeMap<_, _>>();
|
|
||||||
let haystack_keys = haystack.keys().collect::<Vec<_>>();
|
|
||||||
let haystack_keys = haystack_keys.as_str_vec();
|
|
||||||
|
|
||||||
let res = self.match_pattern(pattern, &haystack_keys);
|
|
||||||
|
|
||||||
let matched_repos = res
|
|
||||||
.into_iter()
|
|
||||||
.filter_map(|repo_key| haystack.get(repo_key).map(|r| (*r).to_owned()))
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
|
|
||||||
matched_repos
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,33 +0,0 @@
|
|||||||
use zsh::ZshShell;
|
|
||||||
|
|
||||||
pub mod zsh;
|
|
||||||
|
|
||||||
#[derive(clap::Parser)]
|
|
||||||
pub struct Shell {
|
|
||||||
#[command(subcommand)]
|
|
||||||
shell: ShellSubcommands,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Shell {
|
|
||||||
pub async fn execute(&mut self) -> anyhow::Result<()> {
|
|
||||||
self.shell.execute().await?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(clap::Subcommand)]
|
|
||||||
pub enum ShellSubcommands {
|
|
||||||
#[command()]
|
|
||||||
Zsh(ZshShell),
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ShellSubcommands {
|
|
||||||
pub async fn execute(&mut self) -> anyhow::Result<()> {
|
|
||||||
match self {
|
|
||||||
ShellSubcommands::Zsh(zsh) => zsh.execute().await?,
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,12 +0,0 @@
|
|||||||
#[derive(clap::Parser)]
|
|
||||||
pub struct ZshShell {}
|
|
||||||
|
|
||||||
const SCRIPT: &str = include_str!("../../../include/shell/zsh.sh");
|
|
||||||
|
|
||||||
impl ZshShell {
|
|
||||||
pub async fn execute(&mut self) -> anyhow::Result<()> {
|
|
||||||
println!("{}", SCRIPT);
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,14 +0,0 @@
|
|||||||
use crate::{app::App, cache::CacheApp, projects_list::ProjectsListApp};
|
|
||||||
|
|
||||||
#[derive(clap::Parser)]
|
|
||||||
pub struct Update {}
|
|
||||||
|
|
||||||
impl Update {
|
|
||||||
pub async fn execute(&mut self, app: &'static App) -> anyhow::Result<()> {
|
|
||||||
let repositories = app.projects_list().get_projects().await?;
|
|
||||||
|
|
||||||
app.cache().update(&repositories).await?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,119 +0,0 @@
|
|||||||
use tokio::sync::mpsc::{UnboundedReceiver, UnboundedSender};
|
|
||||||
|
|
||||||
pub mod inline_command;
|
|
||||||
pub mod spinner;
|
|
||||||
|
|
||||||
#[derive(Debug, PartialEq)]
|
|
||||||
pub enum Msg {
|
|
||||||
Quit,
|
|
||||||
Tick,
|
|
||||||
Success,
|
|
||||||
Failure(String),
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct Command {
|
|
||||||
func: Box<CommandFunc>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Command {
|
|
||||||
pub fn new<T: FnOnce(&Dispatch) -> Option<Msg> + 'static>(f: T) -> Self {
|
|
||||||
Self { func: Box::new(f) }
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn execute(self, dispatch: &Dispatch) -> Option<Msg> {
|
|
||||||
(self.func)(dispatch)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub trait IntoCommand {
|
|
||||||
fn into_command(self) -> Command;
|
|
||||||
}
|
|
||||||
|
|
||||||
impl IntoCommand for () {
|
|
||||||
fn into_command(self) -> Command {
|
|
||||||
Command::new(|_| None)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl IntoCommand for Command {
|
|
||||||
fn into_command(self) -> Command {
|
|
||||||
self
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl IntoCommand for Msg {
|
|
||||||
fn into_command(self) -> Command {
|
|
||||||
Command::new(|_| Some(self))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type CommandFunc = dyn FnOnce(&Dispatch) -> Option<Msg>;
|
|
||||||
|
|
||||||
pub fn create_dispatch() -> (Dispatch, Receiver) {
|
|
||||||
let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
|
|
||||||
|
|
||||||
(Dispatch { sender: tx }, Receiver { receiver: rx })
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
|
||||||
pub struct Dispatch {
|
|
||||||
sender: UnboundedSender<Msg>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Dispatch {
|
|
||||||
pub fn send(&self, msg: Msg) {
|
|
||||||
if let Err(e) = self.sender.send(msg) {
|
|
||||||
tracing::warn!("failed to send event: {}", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct Receiver {
|
|
||||||
receiver: UnboundedReceiver<Msg>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Receiver {
|
|
||||||
pub async fn next(&mut self) -> Option<Msg> {
|
|
||||||
self.receiver.recv().await
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Default)]
|
|
||||||
pub struct BatchCommand {
|
|
||||||
commands: Vec<Command>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl BatchCommand {
|
|
||||||
pub fn with(&mut self, cmd: impl IntoCommand) -> &mut Self {
|
|
||||||
self.commands.push(cmd.into_command());
|
|
||||||
|
|
||||||
self
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl IntoCommand for Vec<Command> {
|
|
||||||
fn into_command(self) -> Command {
|
|
||||||
BatchCommand::from(self).into_command()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<Vec<Command>> for BatchCommand {
|
|
||||||
fn from(value: Vec<Command>) -> Self {
|
|
||||||
BatchCommand { commands: value }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl IntoCommand for BatchCommand {
|
|
||||||
fn into_command(self) -> Command {
|
|
||||||
Command::new(|dispatch| {
|
|
||||||
for command in self.commands {
|
|
||||||
let msg = command.execute(dispatch);
|
|
||||||
if let Some(msg) = msg {
|
|
||||||
dispatch.send(msg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
None
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,195 +0,0 @@
|
|||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
use crossterm::event::{EventStream, KeyCode};
|
|
||||||
use futures::{FutureExt, StreamExt};
|
|
||||||
use ratatui::{
|
|
||||||
crossterm,
|
|
||||||
prelude::*,
|
|
||||||
widgets::{Block, Padding},
|
|
||||||
TerminalOptions, Viewport,
|
|
||||||
};
|
|
||||||
|
|
||||||
use crate::components::BatchCommand;
|
|
||||||
|
|
||||||
use super::{
|
|
||||||
create_dispatch,
|
|
||||||
spinner::{Spinner, SpinnerState},
|
|
||||||
Dispatch, IntoCommand, Msg, Receiver,
|
|
||||||
};
|
|
||||||
|
|
||||||
pub struct InlineCommand {
|
|
||||||
spinner: SpinnerState,
|
|
||||||
heading: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl InlineCommand {
|
|
||||||
pub fn new(heading: impl Into<String>) -> Self {
|
|
||||||
Self {
|
|
||||||
spinner: SpinnerState::default(),
|
|
||||||
heading: heading.into(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn execute<F, Fut>(&mut self, func: F) -> anyhow::Result<()>
|
|
||||||
where
|
|
||||||
F: FnOnce() -> Fut + Send + Sync + 'static,
|
|
||||||
Fut: futures::Future<Output = anyhow::Result<()>> + Send + 'static,
|
|
||||||
{
|
|
||||||
tracing::trace!("starting inline terminal");
|
|
||||||
|
|
||||||
let mut terminal = ratatui::init_with_options(TerminalOptions {
|
|
||||||
viewport: Viewport::Inline(3),
|
|
||||||
});
|
|
||||||
|
|
||||||
let (dispatch, mut receiver) = create_dispatch();
|
|
||||||
let mut event_stream = crossterm::event::EventStream::new();
|
|
||||||
let guard = TerminalGuard;
|
|
||||||
|
|
||||||
tokio::spawn({
|
|
||||||
let dispatch = dispatch.clone();
|
|
||||||
|
|
||||||
async move {
|
|
||||||
match func().await {
|
|
||||||
Ok(_) => dispatch.send(Msg::Success),
|
|
||||||
Err(e) => dispatch.send(Msg::Failure(e.to_string())),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
loop {
|
|
||||||
if self
|
|
||||||
.update(&mut terminal, &dispatch, &mut receiver, &mut event_stream)
|
|
||||||
.await?
|
|
||||||
{
|
|
||||||
terminal.draw(|f| {
|
|
||||||
let buf = f.buffer_mut();
|
|
||||||
buf.reset();
|
|
||||||
})?;
|
|
||||||
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
drop(guard);
|
|
||||||
|
|
||||||
println!();
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn update(
|
|
||||||
&mut self,
|
|
||||||
terminal: &mut ratatui::Terminal<impl Backend>,
|
|
||||||
dispatch: &Dispatch,
|
|
||||||
receiver: &mut Receiver,
|
|
||||||
event_stream: &mut EventStream,
|
|
||||||
) -> anyhow::Result<bool> {
|
|
||||||
let input_event = event_stream.next().fuse();
|
|
||||||
let next_msg = receiver.next().fuse();
|
|
||||||
|
|
||||||
const FRAMES_PER_SECOND: f32 = 60.0;
|
|
||||||
const TICK_RATE: f32 = 20.0;
|
|
||||||
|
|
||||||
let period_frame = Duration::from_secs_f32(1.0 / FRAMES_PER_SECOND);
|
|
||||||
let mut interval_frames = tokio::time::interval(period_frame);
|
|
||||||
let period_tick = Duration::from_secs_f32(1.0 / TICK_RATE);
|
|
||||||
let mut interval_ticks = tokio::time::interval(period_tick);
|
|
||||||
|
|
||||||
let msg = tokio::select! {
|
|
||||||
_ = interval_frames.tick() => {
|
|
||||||
terminal.draw(|frame| self.draw(frame))?;
|
|
||||||
None
|
|
||||||
}
|
|
||||||
_ = interval_ticks.tick() => {
|
|
||||||
Some(Msg::Tick)
|
|
||||||
}
|
|
||||||
msg = next_msg => {
|
|
||||||
msg
|
|
||||||
}
|
|
||||||
input = input_event => {
|
|
||||||
if let Some(Ok(input)) = input {
|
|
||||||
self.handle_key_event(input)
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if let Some(msg) = msg {
|
|
||||||
if Msg::Quit == msg {
|
|
||||||
return Ok(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut cmd = self.update_state(&msg);
|
|
||||||
|
|
||||||
loop {
|
|
||||||
let msg = cmd.into_command().execute(dispatch);
|
|
||||||
|
|
||||||
match msg {
|
|
||||||
Some(Msg::Quit) => return Ok(true),
|
|
||||||
Some(msg) => {
|
|
||||||
cmd = self.update_state(&msg);
|
|
||||||
}
|
|
||||||
None => break,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn draw(&mut self, frame: &mut Frame<'_>) {
|
|
||||||
let spinner = Spinner::new(Span::from(&self.heading));
|
|
||||||
|
|
||||||
let block = Block::new().padding(Padding::symmetric(2, 1));
|
|
||||||
|
|
||||||
StatefulWidget::render(
|
|
||||||
spinner.block(block),
|
|
||||||
frame.area(),
|
|
||||||
frame.buffer_mut(),
|
|
||||||
&mut self.spinner,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn handle_key_event(&mut self, event: crossterm::event::Event) -> Option<Msg> {
|
|
||||||
if let crossterm::event::Event::Key(key) = event {
|
|
||||||
return match key.code {
|
|
||||||
KeyCode::Esc => Some(Msg::Quit),
|
|
||||||
KeyCode::Char('c') => Some(Msg::Quit),
|
|
||||||
_ => None,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
fn update_state(&mut self, msg: &Msg) -> impl IntoCommand {
|
|
||||||
tracing::debug!("handling message: {:?}", msg);
|
|
||||||
|
|
||||||
let mut batch = BatchCommand::default();
|
|
||||||
|
|
||||||
match msg {
|
|
||||||
Msg::Quit => {}
|
|
||||||
Msg::Tick => {}
|
|
||||||
Msg::Success => return Msg::Quit.into_command(),
|
|
||||||
Msg::Failure(f) => {
|
|
||||||
tracing::error!("command failed: {}", f);
|
|
||||||
return Msg::Quit.into_command();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
batch.with(self.spinner.update(msg));
|
|
||||||
|
|
||||||
batch.into_command()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Default)]
|
|
||||||
struct TerminalGuard;
|
|
||||||
|
|
||||||
impl Drop for TerminalGuard {
|
|
||||||
fn drop(&mut self) {
|
|
||||||
tracing::trace!("restoring inline terminal");
|
|
||||||
ratatui::restore();
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,86 +0,0 @@
|
|||||||
use std::time::{Duration, Instant};
|
|
||||||
|
|
||||||
use ratatui::{
|
|
||||||
text::{Line, Span},
|
|
||||||
widgets::{Block, Paragraph, StatefulWidget, Widget},
|
|
||||||
};
|
|
||||||
|
|
||||||
use super::{BatchCommand, IntoCommand, Msg};
|
|
||||||
|
|
||||||
pub struct Spinner<'a> {
|
|
||||||
span: Span<'a>,
|
|
||||||
block: Option<Block<'a>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<'a> Spinner<'a> {
|
|
||||||
pub fn new(span: Span<'a>) -> Self {
|
|
||||||
Self { span, block: None }
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn block(mut self, block: Block<'a>) -> Self {
|
|
||||||
self.block = Some(block);
|
|
||||||
self
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<'a> StatefulWidget for Spinner<'a> {
|
|
||||||
type State = SpinnerState;
|
|
||||||
|
|
||||||
fn render(
|
|
||||||
self,
|
|
||||||
area: ratatui::prelude::Rect,
|
|
||||||
buf: &mut ratatui::prelude::Buffer,
|
|
||||||
state: &mut Self::State,
|
|
||||||
) {
|
|
||||||
let frame = MINIDOT_FRAMES
|
|
||||||
.get((state.frame) % MINIDOT_FRAMES.len())
|
|
||||||
.expect("to find a valid static frame");
|
|
||||||
|
|
||||||
let line = Line::from(vec![Span::from(*frame), Span::from(" "), self.span]);
|
|
||||||
|
|
||||||
let para = Paragraph::new(vec![line]);
|
|
||||||
let para = if let Some(block) = self.block {
|
|
||||||
para.block(block)
|
|
||||||
} else {
|
|
||||||
para
|
|
||||||
};
|
|
||||||
|
|
||||||
para.render(area, buf)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct SpinnerState {
|
|
||||||
last_event: Instant,
|
|
||||||
interval: Duration,
|
|
||||||
frame: usize,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for SpinnerState {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self {
|
|
||||||
last_event: Instant::now(),
|
|
||||||
interval: Duration::from_millis(1000 / 12),
|
|
||||||
frame: 0,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const MINIDOT_FRAMES: [&str; 10] = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
||||||
|
|
||||||
impl SpinnerState {
|
|
||||||
pub fn update(&mut self, _msg: &Msg) -> impl IntoCommand {
|
|
||||||
let batch = BatchCommand::default();
|
|
||||||
|
|
||||||
let now = Instant::now();
|
|
||||||
if now.duration_since(self.last_event) >= self.interval {
|
|
||||||
self.last_event = now;
|
|
||||||
self.next_state();
|
|
||||||
}
|
|
||||||
|
|
||||||
batch
|
|
||||||
}
|
|
||||||
|
|
||||||
fn next_state(&mut self) {
|
|
||||||
self.frame = self.frame.wrapping_add(1);
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,397 +0,0 @@
|
|||||||
use std::path::{Path, PathBuf};
|
|
||||||
|
|
||||||
use anyhow::Context;
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize, PartialEq)]
|
|
||||||
pub struct Config {
|
|
||||||
#[serde(default)]
|
|
||||||
pub settings: Settings,
|
|
||||||
|
|
||||||
#[serde(default)]
|
|
||||||
pub providers: Providers,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Default, Serialize, Deserialize, PartialEq)]
|
|
||||||
pub struct Settings {
|
|
||||||
#[serde(default)]
|
|
||||||
pub projects: Projects,
|
|
||||||
|
|
||||||
#[serde(default)]
|
|
||||||
pub cache: Cache,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Default, Serialize, Deserialize, PartialEq)]
|
|
||||||
pub struct Projects {
|
|
||||||
pub directory: ProjectLocation,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
|
||||||
pub struct ProjectLocation(PathBuf);
|
|
||||||
|
|
||||||
impl From<PathBuf> for ProjectLocation {
|
|
||||||
fn from(value: PathBuf) -> Self {
|
|
||||||
Self(value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<ProjectLocation> for PathBuf {
|
|
||||||
fn from(value: ProjectLocation) -> Self {
|
|
||||||
value.0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for ProjectLocation {
|
|
||||||
fn default() -> Self {
|
|
||||||
let home = dirs::home_dir().unwrap_or_default();
|
|
||||||
|
|
||||||
Self(home.join("git"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl std::ops::Deref for ProjectLocation {
|
|
||||||
type Target = PathBuf;
|
|
||||||
|
|
||||||
fn deref(&self) -> &Self::Target {
|
|
||||||
&self.0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Default, Serialize, Deserialize, PartialEq)]
|
|
||||||
pub struct Cache {
|
|
||||||
#[serde(default)]
|
|
||||||
pub location: CacheLocation,
|
|
||||||
|
|
||||||
#[serde(default)]
|
|
||||||
pub duration: CacheDuration,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
|
||||||
pub struct CacheLocation(PathBuf);
|
|
||||||
|
|
||||||
impl From<PathBuf> for CacheLocation {
|
|
||||||
fn from(value: PathBuf) -> Self {
|
|
||||||
Self(value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<CacheLocation> for PathBuf {
|
|
||||||
fn from(value: CacheLocation) -> Self {
|
|
||||||
value.0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for CacheLocation {
|
|
||||||
fn default() -> Self {
|
|
||||||
let home = dirs::home_dir().unwrap_or_default();
|
|
||||||
|
|
||||||
Self(home.join(".cache/gitnow"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize, PartialEq)]
|
|
||||||
#[serde(untagged)]
|
|
||||||
pub enum CacheDuration {
|
|
||||||
Enabled(bool),
|
|
||||||
Precise {
|
|
||||||
#[serde(default)]
|
|
||||||
days: u64,
|
|
||||||
#[serde(default)]
|
|
||||||
hours: u64,
|
|
||||||
#[serde(default)]
|
|
||||||
minutes: u64,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
impl CacheDuration {
|
|
||||||
pub fn get_duration(&self) -> Option<std::time::Duration> {
|
|
||||||
match self {
|
|
||||||
CacheDuration::Enabled(true) => CacheDuration::default().get_duration(),
|
|
||||||
CacheDuration::Enabled(false) => None,
|
|
||||||
CacheDuration::Precise {
|
|
||||||
days,
|
|
||||||
hours,
|
|
||||||
minutes,
|
|
||||||
} => Some(
|
|
||||||
std::time::Duration::from_days(*days)
|
|
||||||
+ std::time::Duration::from_hours(*hours)
|
|
||||||
+ std::time::Duration::from_mins(*minutes),
|
|
||||||
),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for CacheDuration {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self::Precise {
|
|
||||||
days: 1,
|
|
||||||
hours: 0,
|
|
||||||
minutes: 0,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Default, Serialize, Deserialize, PartialEq)]
|
|
||||||
pub struct Providers {
|
|
||||||
#[serde(default)]
|
|
||||||
pub github: Vec<GitHub>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub gitea: Vec<Gitea>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize, PartialEq)]
|
|
||||||
pub struct GitHub {
|
|
||||||
#[serde(default)]
|
|
||||||
pub url: Option<String>,
|
|
||||||
|
|
||||||
pub access_token: GitHubAccessToken,
|
|
||||||
|
|
||||||
#[serde(default)]
|
|
||||||
pub current_user: Option<String>,
|
|
||||||
|
|
||||||
#[serde(default)]
|
|
||||||
pub users: Vec<GitHubUser>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub organisations: Vec<GitHubOrganisation>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize, PartialEq)]
|
|
||||||
pub struct GitHubUser(String);
|
|
||||||
|
|
||||||
impl From<GitHubUser> for String {
|
|
||||||
fn from(value: GitHubUser) -> Self {
|
|
||||||
value.0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<'a> From<&'a GitHubUser> for &'a str {
|
|
||||||
fn from(value: &'a GitHubUser) -> Self {
|
|
||||||
value.0.as_str()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize, PartialEq)]
|
|
||||||
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)]
|
|
||||||
pub struct Gitea {
|
|
||||||
pub url: String,
|
|
||||||
|
|
||||||
#[serde(default)]
|
|
||||||
pub access_token: Option<GiteaAccessToken>,
|
|
||||||
|
|
||||||
#[serde(default)]
|
|
||||||
pub current_user: Option<String>,
|
|
||||||
|
|
||||||
#[serde(default)]
|
|
||||||
pub users: Vec<GiteaUser>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub organisations: Vec<GiteaOrganisation>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize, PartialEq)]
|
|
||||||
#[serde(untagged)]
|
|
||||||
pub enum GiteaAccessToken {
|
|
||||||
Direct(String),
|
|
||||||
Env { env: String },
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize, PartialEq)]
|
|
||||||
#[serde(untagged)]
|
|
||||||
pub enum GitHubAccessToken {
|
|
||||||
Direct(String),
|
|
||||||
Env { env: String },
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize, PartialEq)]
|
|
||||||
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)]
|
|
||||||
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 {
|
|
||||||
pub async fn from_file(file_path: &Path) -> anyhow::Result<Config> {
|
|
||||||
if !file_path.exists() {
|
|
||||||
if let Some(parent) = file_path.parent() {
|
|
||||||
tokio::fs::create_dir_all(parent).await?;
|
|
||||||
}
|
|
||||||
tokio::fs::File::create(file_path).await?;
|
|
||||||
}
|
|
||||||
|
|
||||||
let file_content = tokio::fs::read_to_string(file_path).await?;
|
|
||||||
|
|
||||||
Self::from_string(&file_content)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn from_string(content: &str) -> anyhow::Result<Config> {
|
|
||||||
toml::from_str(content).context("failed to deserialize config file")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod test {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_can_parse_config() -> anyhow::Result<()> {
|
|
||||||
let content = r#"
|
|
||||||
[settings]
|
|
||||||
projects = { directory = "git" }
|
|
||||||
|
|
||||||
[settings.cache]
|
|
||||||
location = ".cache/gitnow"
|
|
||||||
duration = { days = 2 }
|
|
||||||
|
|
||||||
[[providers.github]]
|
|
||||||
current_user = "kjuulh"
|
|
||||||
access_token = "some-token"
|
|
||||||
users = ["kjuulh"]
|
|
||||||
organisations = ["lunarway"]
|
|
||||||
|
|
||||||
[[providers.github]]
|
|
||||||
access_token = { env = "something" }
|
|
||||||
users = ["other"]
|
|
||||||
organisations = ["org"]
|
|
||||||
|
|
||||||
[[providers.gitea]]
|
|
||||||
url = "https://git.front.kjuulh.io/api/v1"
|
|
||||||
current_user = "kjuulh"
|
|
||||||
users = ["kjuulh"]
|
|
||||||
organisations = ["lunarway"]
|
|
||||||
|
|
||||||
[[providers.gitea]]
|
|
||||||
url = "https://git.front.kjuulh.io/api/v1"
|
|
||||||
users = ["other"]
|
|
||||||
organisations = ["org"]
|
|
||||||
|
|
||||||
[[providers.gitea]]
|
|
||||||
url = "https://git.front.kjuulh.io/api/v1"
|
|
||||||
"#;
|
|
||||||
|
|
||||||
let config = Config::from_string(content)?;
|
|
||||||
|
|
||||||
pretty_assertions::assert_eq!(
|
|
||||||
Config {
|
|
||||||
providers: Providers {
|
|
||||||
github: vec![
|
|
||||||
GitHub {
|
|
||||||
users: vec![GitHubUser("kjuulh".into())],
|
|
||||||
organisations: vec![GitHubOrganisation("lunarway".into())],
|
|
||||||
url: None,
|
|
||||||
access_token: GitHubAccessToken::Direct("some-token".into()),
|
|
||||||
current_user: Some("kjuulh".into())
|
|
||||||
},
|
|
||||||
GitHub {
|
|
||||||
users: vec![GitHubUser("other".into())],
|
|
||||||
organisations: vec![GitHubOrganisation("org".into())],
|
|
||||||
url: None,
|
|
||||||
access_token: GitHubAccessToken::Env {
|
|
||||||
env: "something".into()
|
|
||||||
},
|
|
||||||
current_user: None
|
|
||||||
}
|
|
||||||
],
|
|
||||||
gitea: vec![
|
|
||||||
Gitea {
|
|
||||||
url: "https://git.front.kjuulh.io/api/v1".into(),
|
|
||||||
users: vec![GiteaUser("kjuulh".into())],
|
|
||||||
organisations: vec![GiteaOrganisation("lunarway".into())],
|
|
||||||
access_token: None,
|
|
||||||
current_user: Some("kjuulh".into())
|
|
||||||
},
|
|
||||||
Gitea {
|
|
||||||
url: "https://git.front.kjuulh.io/api/v1".into(),
|
|
||||||
users: vec![GiteaUser("other".into())],
|
|
||||||
organisations: vec![GiteaOrganisation("org".into())],
|
|
||||||
access_token: None,
|
|
||||||
current_user: None
|
|
||||||
},
|
|
||||||
Gitea {
|
|
||||||
url: "https://git.front.kjuulh.io/api/v1".into(),
|
|
||||||
users: vec![],
|
|
||||||
organisations: vec![],
|
|
||||||
access_token: None,
|
|
||||||
current_user: None
|
|
||||||
},
|
|
||||||
]
|
|
||||||
},
|
|
||||||
settings: Settings {
|
|
||||||
cache: Cache {
|
|
||||||
location: PathBuf::from(".cache/gitnow").into(),
|
|
||||||
duration: CacheDuration::Precise {
|
|
||||||
days: 2,
|
|
||||||
hours: 0,
|
|
||||||
minutes: 0
|
|
||||||
}
|
|
||||||
},
|
|
||||||
projects: Projects {
|
|
||||||
directory: PathBuf::from("git").into()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
config
|
|
||||||
);
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_can_parse_empty_config() -> anyhow::Result<()> {
|
|
||||||
let content = r#"
|
|
||||||
# empty file
|
|
||||||
"#;
|
|
||||||
|
|
||||||
let config = Config::from_string(content)?;
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
Config {
|
|
||||||
providers: Providers {
|
|
||||||
github: vec![],
|
|
||||||
gitea: vec![]
|
|
||||||
},
|
|
||||||
settings: Settings {
|
|
||||||
cache: Cache::default(),
|
|
||||||
projects: Projects::default()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
config
|
|
||||||
);
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,34 +0,0 @@
|
|||||||
use nucleo_matcher::{pattern::Pattern, Matcher};
|
|
||||||
|
|
||||||
use crate::app::App;
|
|
||||||
|
|
||||||
pub struct FuzzyMatcher {}
|
|
||||||
|
|
||||||
impl FuzzyMatcher {
|
|
||||||
pub fn new() -> Self {
|
|
||||||
Self {}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn match_pattern<'a>(&self, pattern: &'a str, items: &'a [&'a str]) -> Vec<&'a str> {
|
|
||||||
let pat = Pattern::new(
|
|
||||||
pattern,
|
|
||||||
nucleo_matcher::pattern::CaseMatching::Ignore,
|
|
||||||
nucleo_matcher::pattern::Normalization::Smart,
|
|
||||||
nucleo_matcher::pattern::AtomKind::Fuzzy,
|
|
||||||
);
|
|
||||||
let mut matcher = Matcher::new(nucleo_matcher::Config::DEFAULT);
|
|
||||||
let res = pat.match_list(items, &mut matcher);
|
|
||||||
|
|
||||||
res.into_iter().map(|(item, _)| *item).collect::<Vec<_>>()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub trait FuzzyMatcherApp {
|
|
||||||
fn fuzzy_matcher(&self) -> FuzzyMatcher;
|
|
||||||
}
|
|
||||||
|
|
||||||
impl FuzzyMatcherApp for &'static App {
|
|
||||||
fn fuzzy_matcher(&self) -> FuzzyMatcher {
|
|
||||||
FuzzyMatcher::new()
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,21 +0,0 @@
|
|||||||
// @generated
|
|
||||||
// This file is @generated by prost-build.
|
|
||||||
#[allow(clippy::derive_partial_eq_without_eq)]
|
|
||||||
#[derive(Clone, PartialEq, ::prost::Message)]
|
|
||||||
pub struct Repositories {
|
|
||||||
#[prost(message, repeated, tag="1")]
|
|
||||||
pub repositories: ::prost::alloc::vec::Vec<Repository>,
|
|
||||||
}
|
|
||||||
#[allow(clippy::derive_partial_eq_without_eq)]
|
|
||||||
#[derive(Clone, PartialEq, ::prost::Message)]
|
|
||||||
pub struct Repository {
|
|
||||||
#[prost(string, tag="1")]
|
|
||||||
pub provider: ::prost::alloc::string::String,
|
|
||||||
#[prost(string, tag="2")]
|
|
||||||
pub owner: ::prost::alloc::string::String,
|
|
||||||
#[prost(string, tag="3")]
|
|
||||||
pub repo_name: ::prost::alloc::string::String,
|
|
||||||
#[prost(string, tag="4")]
|
|
||||||
pub ssh_url: ::prost::alloc::string::String,
|
|
||||||
}
|
|
||||||
// @@protoc_insertion_point(module)
|
|
@ -1,83 +0,0 @@
|
|||||||
use crate::{app::App, git_provider::Repository};
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct GitClone {
|
|
||||||
app: &'static App,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl GitClone {
|
|
||||||
pub fn new(app: &'static App) -> Self {
|
|
||||||
Self { app }
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn clone_repo(
|
|
||||||
&self,
|
|
||||||
repository: &Repository,
|
|
||||||
force_refresh: bool,
|
|
||||||
) -> anyhow::Result<()> {
|
|
||||||
let project_path = self
|
|
||||||
.app
|
|
||||||
.config
|
|
||||||
.settings
|
|
||||||
.projects
|
|
||||||
.directory
|
|
||||||
.join(repository.to_rel_path());
|
|
||||||
|
|
||||||
if force_refresh {
|
|
||||||
tokio::fs::remove_dir_all(&project_path).await?;
|
|
||||||
}
|
|
||||||
|
|
||||||
if project_path.exists() {
|
|
||||||
tracing::info!(
|
|
||||||
"project: {} already exists, skipping clone",
|
|
||||||
repository.to_rel_path().display()
|
|
||||||
);
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
tracing::info!(
|
|
||||||
"cloning: {} into {}",
|
|
||||||
repository.ssh_url.as_str(),
|
|
||||||
&project_path.display().to_string(),
|
|
||||||
);
|
|
||||||
|
|
||||||
let mut cmd = tokio::process::Command::new("git");
|
|
||||||
cmd.args([
|
|
||||||
"clone",
|
|
||||||
repository.ssh_url.as_str(),
|
|
||||||
&project_path.display().to_string(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
let output = cmd.output().await?;
|
|
||||||
match output.status.success() {
|
|
||||||
true => tracing::debug!(
|
|
||||||
"cloned {} into {}",
|
|
||||||
repository.ssh_url.as_str(),
|
|
||||||
&project_path.display().to_string(),
|
|
||||||
),
|
|
||||||
false => {
|
|
||||||
let stdout = std::str::from_utf8(&output.stdout).unwrap_or_default();
|
|
||||||
let stderr = std::str::from_utf8(&output.stderr).unwrap_or_default();
|
|
||||||
tracing::error!(
|
|
||||||
"failed to clone {} into {}, with output: {}, err: {}",
|
|
||||||
repository.ssh_url.as_str(),
|
|
||||||
&project_path.display().to_string(),
|
|
||||||
stdout,
|
|
||||||
stderr
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub trait GitCloneApp {
|
|
||||||
fn git_clone(&self) -> GitClone;
|
|
||||||
}
|
|
||||||
|
|
||||||
impl GitCloneApp for &'static App {
|
|
||||||
fn git_clone(&self) -> GitClone {
|
|
||||||
GitClone::new(self)
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,33 +0,0 @@
|
|||||||
use std::path::PathBuf;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, PartialOrd)]
|
|
||||||
pub struct Repository {
|
|
||||||
pub provider: String,
|
|
||||||
pub owner: String,
|
|
||||||
pub repo_name: String,
|
|
||||||
pub ssh_url: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Repository {
|
|
||||||
pub fn to_rel_path(&self) -> PathBuf {
|
|
||||||
PathBuf::from(&self.provider)
|
|
||||||
.join(&self.owner)
|
|
||||||
.join(&self.repo_name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub trait VecRepositoryExt {
|
|
||||||
fn collect_unique(&mut self) -> &mut Self;
|
|
||||||
}
|
|
||||||
|
|
||||||
impl VecRepositoryExt for Vec<Repository> {
|
|
||||||
fn collect_unique(&mut self) -> &mut Self {
|
|
||||||
self.sort_by_key(|a| a.to_rel_path());
|
|
||||||
self.dedup_by_key(|a| a.to_rel_path());
|
|
||||||
|
|
||||||
self
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub mod gitea;
|
|
||||||
pub mod github;
|
|
@ -1,232 +0,0 @@
|
|||||||
use anyhow::Context;
|
|
||||||
use gitea_client::apis::configuration::Configuration;
|
|
||||||
use url::Url;
|
|
||||||
|
|
||||||
use crate::{app::App, config::GiteaAccessToken};
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub struct GiteaProvider {
|
|
||||||
#[allow(dead_code)]
|
|
||||||
app: &'static App,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl GiteaProvider {
|
|
||||||
pub fn new(app: &'static App) -> GiteaProvider {
|
|
||||||
GiteaProvider { app }
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn list_repositories_for_current_user(
|
|
||||||
&self,
|
|
||||||
api: &str,
|
|
||||||
access_token: Option<&GiteaAccessToken>,
|
|
||||||
) -> anyhow::Result<Vec<super::Repository>> {
|
|
||||||
tracing::debug!("fetching gitea repositories for current 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_current_user_with_page(&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 a gitea repository"),
|
|
||||||
})
|
|
||||||
.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 {
|
|
||||||
fn gitea_provider(&self) -> GiteaProvider;
|
|
||||||
}
|
|
||||||
|
|
||||||
impl GiteaProviderApp for &'static App {
|
|
||||||
fn gitea_provider(&self) -> GiteaProvider {
|
|
||||||
GiteaProvider::new(self)
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,178 +0,0 @@
|
|||||||
use octocrab::{models::Repository, params::repos::Sort, Octocrab, Page};
|
|
||||||
|
|
||||||
use crate::{app::App, config::GitHubAccessToken};
|
|
||||||
|
|
||||||
pub struct GitHubProvider {
|
|
||||||
#[allow(dead_code)]
|
|
||||||
app: &'static App,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl GitHubProvider {
|
|
||||||
pub fn new(app: &'static App) -> GitHubProvider {
|
|
||||||
GitHubProvider { app }
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn list_repositories_for_current_user(
|
|
||||||
&self,
|
|
||||||
url: Option<&String>,
|
|
||||||
access_token: &GitHubAccessToken,
|
|
||||||
) -> anyhow::Result<Vec<super::Repository>> {
|
|
||||||
tracing::debug!("fetching github repositories for current user");
|
|
||||||
|
|
||||||
let client = self.get_client(url, access_token)?;
|
|
||||||
|
|
||||||
let current_page = client
|
|
||||||
.current()
|
|
||||||
.list_repos_for_authenticated_user()
|
|
||||||
.type_("all")
|
|
||||||
.per_page(100)
|
|
||||||
.sort("full_name")
|
|
||||||
.send()
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
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(
|
|
||||||
&self,
|
|
||||||
user: &str,
|
|
||||||
url: Option<&String>,
|
|
||||||
access_token: &GitHubAccessToken,
|
|
||||||
) -> anyhow::Result<Vec<super::Repository>> {
|
|
||||||
tracing::debug!(user = user, "fetching github repositories for user");
|
|
||||||
|
|
||||||
let client = self.get_client(url, access_token)?;
|
|
||||||
|
|
||||||
let current_page = client
|
|
||||||
.users(user)
|
|
||||||
.repos()
|
|
||||||
.r#type(octocrab::params::users::repos::Type::All)
|
|
||||||
.sort(Sort::FullName)
|
|
||||||
.per_page(100)
|
|
||||||
.send()
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
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(
|
|
||||||
&self,
|
|
||||||
organisation: &str,
|
|
||||||
url: Option<&String>,
|
|
||||||
access_token: &GitHubAccessToken,
|
|
||||||
) -> anyhow::Result<Vec<super::Repository>> {
|
|
||||||
tracing::debug!(
|
|
||||||
organisation = organisation,
|
|
||||||
"fetching github repositories for organisation"
|
|
||||||
);
|
|
||||||
|
|
||||||
let client = self.get_client(url, access_token)?;
|
|
||||||
|
|
||||||
let current_page = client
|
|
||||||
.orgs(organisation)
|
|
||||||
.list_repos()
|
|
||||||
.repo_type(Some(octocrab::params::repos::Type::All))
|
|
||||||
.sort(Sort::FullName)
|
|
||||||
.per_page(100)
|
|
||||||
.send()
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
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())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn unfold_pages(
|
|
||||||
&self,
|
|
||||||
client: octocrab::Octocrab,
|
|
||||||
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 {
|
|
||||||
let default_domain = "github.com".to_string();
|
|
||||||
|
|
||||||
if let Some(url) = url {
|
|
||||||
let Some(url) = url::Url::parse(url).ok() else {
|
|
||||||
return default_domain;
|
|
||||||
};
|
|
||||||
|
|
||||||
let Some(domain) = url.domain().map(|d| d.to_string()) else {
|
|
||||||
return default_domain;
|
|
||||||
};
|
|
||||||
|
|
||||||
domain
|
|
||||||
} else {
|
|
||||||
default_domain
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn get_client(
|
|
||||||
&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::Env { env } => std::env::var(env)?,
|
|
||||||
})
|
|
||||||
.build()?;
|
|
||||||
|
|
||||||
Ok(client)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub trait GitHubProviderApp {
|
|
||||||
fn github_provider(&self) -> GitHubProvider;
|
|
||||||
}
|
|
||||||
|
|
||||||
impl GitHubProviderApp for &'static App {
|
|
||||||
fn github_provider(&self) -> GitHubProvider {
|
|
||||||
GitHubProvider::new(self)
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,166 +0,0 @@
|
|||||||
use app::App;
|
|
||||||
use ratatui::{prelude::*, Terminal};
|
|
||||||
|
|
||||||
use crate::git_provider::Repository;
|
|
||||||
|
|
||||||
pub struct Interactive {
|
|
||||||
app: &'static crate::app::App,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Interactive {
|
|
||||||
pub fn new(app: &'static crate::app::App) -> Self {
|
|
||||||
Self { app }
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn interactive_search(
|
|
||||||
&mut self,
|
|
||||||
repositories: &[Repository],
|
|
||||||
) -> anyhow::Result<Option<Repository>> {
|
|
||||||
let backend = TermwizBackend::new().map_err(|e| anyhow::anyhow!(e.to_string()))?;
|
|
||||||
let terminal = Terminal::new(backend)?;
|
|
||||||
|
|
||||||
let app_result = App::new(self.app, repositories).run(terminal);
|
|
||||||
|
|
||||||
app_result
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub trait InteractiveApp {
|
|
||||||
fn interactive(&self) -> Interactive;
|
|
||||||
}
|
|
||||||
|
|
||||||
impl InteractiveApp for &'static crate::app::App {
|
|
||||||
fn interactive(&self) -> Interactive {
|
|
||||||
Interactive::new(self)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
mod app {
|
|
||||||
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::{
|
|
||||||
commands::root::RepositoryMatcher, fuzzy_matcher::FuzzyMatcherApp, git_provider::Repository,
|
|
||||||
};
|
|
||||||
|
|
||||||
pub struct App<'a> {
|
|
||||||
app: &'static crate::app::App,
|
|
||||||
repositories: &'a [Repository],
|
|
||||||
current_search: String,
|
|
||||||
matched_repos: Vec<Repository>,
|
|
||||||
list: ListState,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<'a> App<'a> {
|
|
||||||
pub fn new(app: &'static crate::app::App, repositories: &'a [Repository]) -> Self {
|
|
||||||
Self {
|
|
||||||
app,
|
|
||||||
repositories,
|
|
||||||
current_search: String::default(),
|
|
||||||
matched_repos: Vec::default(),
|
|
||||||
list: ListState::default(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn update_matched_repos(&mut self) {
|
|
||||||
let res = self
|
|
||||||
.app
|
|
||||||
.fuzzy_matcher()
|
|
||||||
.match_repositories(&self.current_search, self.repositories);
|
|
||||||
|
|
||||||
self.matched_repos = res;
|
|
||||||
|
|
||||||
if self.list.selected().is_none() {
|
|
||||||
self.list.select_first();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn run(
|
|
||||||
mut self,
|
|
||||||
mut terminal: Terminal<TermwizBackend>,
|
|
||||||
) -> anyhow::Result<Option<Repository>> {
|
|
||||||
self.update_matched_repos();
|
|
||||||
|
|
||||||
loop {
|
|
||||||
terminal.draw(|frame| self.draw(frame))?;
|
|
||||||
|
|
||||||
if let Event::Key(key) = event::read()? {
|
|
||||||
match key.code {
|
|
||||||
KeyCode::Char(letter) => {
|
|
||||||
self.current_search.push(letter);
|
|
||||||
self.update_matched_repos();
|
|
||||||
}
|
|
||||||
KeyCode::Backspace => {
|
|
||||||
if !self.current_search.is_empty() {
|
|
||||||
let _ = self.current_search.remove(self.current_search.len() - 1);
|
|
||||||
self.update_matched_repos();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
KeyCode::Esc => {
|
|
||||||
return Ok(None);
|
|
||||||
}
|
|
||||||
KeyCode::Enter => {
|
|
||||||
if let Some(selected) = self.list.selected() {
|
|
||||||
if let Some(repo) = self.matched_repos.get(selected).cloned() {
|
|
||||||
terminal.resize(ratatui::layout::Rect::ZERO)?;
|
|
||||||
return Ok(Some(repo));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return Ok(None);
|
|
||||||
}
|
|
||||||
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)]);
|
|
||||||
let [repository_area, input_area] = vertical.areas(frame.area());
|
|
||||||
|
|
||||||
let repos = &self.matched_repos;
|
|
||||||
|
|
||||||
let repo_items = repos
|
|
||||||
.iter()
|
|
||||||
.map(|r| r.to_rel_path().display().to_string())
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
|
|
||||||
let repo_list_items = repo_items
|
|
||||||
.into_iter()
|
|
||||||
.map(ListItem::from)
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
|
|
||||||
let repo_list = ratatui::widgets::List::new(repo_list_items)
|
|
||||||
.direction(ratatui::widgets::ListDirection::BottomToTop)
|
|
||||||
.scroll_padding(3)
|
|
||||||
.highlight_symbol("> ")
|
|
||||||
.highlight_spacing(ratatui::widgets::HighlightSpacing::Always)
|
|
||||||
.highlight_style(Style::default().bold().white());
|
|
||||||
|
|
||||||
StatefulWidget::render(
|
|
||||||
repo_list,
|
|
||||||
repository_area,
|
|
||||||
frame.buffer_mut(),
|
|
||||||
&mut self.list,
|
|
||||||
);
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,106 +1,28 @@
|
|||||||
#![feature(duration_constructors)]
|
|
||||||
|
|
||||||
use std::path::PathBuf;
|
|
||||||
|
|
||||||
use anyhow::Context;
|
use anyhow::Context;
|
||||||
use clap::{Parser, Subcommand};
|
use clap::{Parser, Subcommand};
|
||||||
use commands::{root::RootCommand, shell::Shell, update::Update};
|
|
||||||
use config::Config;
|
|
||||||
use tracing::level_filters::LevelFilter;
|
|
||||||
use tracing_subscriber::EnvFilter;
|
|
||||||
|
|
||||||
mod app;
|
|
||||||
mod cache;
|
|
||||||
mod cache_codec;
|
|
||||||
mod commands;
|
|
||||||
mod components;
|
|
||||||
mod config;
|
|
||||||
mod fuzzy_matcher;
|
|
||||||
mod git_clone;
|
|
||||||
mod git_provider;
|
|
||||||
mod interactive;
|
|
||||||
mod projects_list;
|
|
||||||
mod shell;
|
|
||||||
|
|
||||||
#[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 = None, subcommand_required = true)]
|
||||||
struct Command {
|
struct Command {
|
||||||
#[command(subcommand)]
|
#[command(subcommand)]
|
||||||
command: Option<Commands>,
|
command: Option<Commands>,
|
||||||
|
|
||||||
#[arg()]
|
|
||||||
search: Option<String>,
|
|
||||||
|
|
||||||
#[arg(long = "no-cache", default_value = "false")]
|
|
||||||
no_cache: bool,
|
|
||||||
|
|
||||||
#[arg(long = "no-clone", default_value = "false")]
|
|
||||||
no_clone: bool,
|
|
||||||
|
|
||||||
#[arg(long = "no-shell", default_value = "false")]
|
|
||||||
no_shell: bool,
|
|
||||||
|
|
||||||
#[arg(long = "force-refresh", default_value = "false")]
|
|
||||||
force_refresh: bool,
|
|
||||||
|
|
||||||
#[arg(long = "force-cache-update", default_value = "false")]
|
|
||||||
force_cache_update: bool,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Subcommand)]
|
#[derive(Subcommand)]
|
||||||
enum Commands {
|
enum Commands {
|
||||||
Init(Shell),
|
Hello {},
|
||||||
Update(Update),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_CONFIG_PATH: &str = ".config/gitnow/gitnow.toml";
|
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() -> anyhow::Result<()> {
|
async fn main() -> anyhow::Result<()> {
|
||||||
dotenv::dotenv().ok();
|
dotenv::dotenv().ok();
|
||||||
tracing_subscriber::fmt()
|
tracing_subscriber::fmt::init();
|
||||||
.with_env_filter(
|
|
||||||
EnvFilter::builder()
|
|
||||||
.with_default_directive(LevelFilter::ERROR.into())
|
|
||||||
.from_env_lossy(),
|
|
||||||
)
|
|
||||||
.init();
|
|
||||||
|
|
||||||
let home =
|
|
||||||
std::env::var("HOME").context("HOME was not found, are you using a proper shell?")?;
|
|
||||||
let default_config_path = PathBuf::from(home).join(DEFAULT_CONFIG_PATH);
|
|
||||||
let config_path = std::env::var("GITNOW_CONFIG")
|
|
||||||
.map(PathBuf::from)
|
|
||||||
.unwrap_or(default_config_path);
|
|
||||||
|
|
||||||
let config = Config::from_file(&config_path).await?;
|
|
||||||
|
|
||||||
let app = app::App::new_static(config).await?;
|
|
||||||
|
|
||||||
let cli = Command::parse();
|
let cli = Command::parse();
|
||||||
tracing::debug!("Starting cli");
|
tracing::debug!("Starting cli");
|
||||||
|
|
||||||
match cli.command {
|
if let Some(Commands::Hello {}) = cli.command {
|
||||||
Some(cmd) => match cmd {
|
println!("Hello!")
|
||||||
Commands::Init(mut shell) => {
|
|
||||||
shell.execute().await?;
|
|
||||||
}
|
|
||||||
Commands::Update(mut update) => {
|
|
||||||
update.execute(app).await?;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
None => {
|
|
||||||
RootCommand::new(app)
|
|
||||||
.execute(
|
|
||||||
cli.search.as_ref(),
|
|
||||||
!cli.no_cache,
|
|
||||||
!cli.no_clone,
|
|
||||||
!cli.no_shell,
|
|
||||||
cli.force_refresh,
|
|
||||||
cli.force_cache_update,
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
|
@ -1,135 +0,0 @@
|
|||||||
#[cfg(not(feature = "example"))]
|
|
||||||
pub use implementation::*;
|
|
||||||
|
|
||||||
#[cfg(feature = "example")]
|
|
||||||
pub use example_projects::*;
|
|
||||||
|
|
||||||
use crate::app::App;
|
|
||||||
|
|
||||||
pub trait ProjectsListApp {
|
|
||||||
fn projects_list(&self) -> ProjectsList;
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ProjectsListApp for &'static App {
|
|
||||||
fn projects_list(&self) -> ProjectsList {
|
|
||||||
ProjectsList::new(self)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
mod implementation {
|
|
||||||
use crate::{
|
|
||||||
app::App,
|
|
||||||
git_provider::{
|
|
||||||
gitea::GiteaProviderApp, github::GitHubProviderApp, Repository, VecRepositoryExt,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
pub struct ProjectsList {
|
|
||||||
app: &'static App,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ProjectsList {
|
|
||||||
pub fn new(app: &'static App) -> Self {
|
|
||||||
Self { app }
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn get_projects(&self) -> anyhow::Result<Vec<Repository>> {
|
|
||||||
let mut repositories = Vec::new();
|
|
||||||
|
|
||||||
repositories.extend(self.get_gitea_projects().await?);
|
|
||||||
repositories.extend(self.get_github_projects().await?);
|
|
||||||
|
|
||||||
repositories.collect_unique();
|
|
||||||
|
|
||||||
Ok(repositories)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn get_gitea_projects(&self) -> anyhow::Result<Vec<Repository>> {
|
|
||||||
let gitea_provider = self.app.gitea_provider();
|
|
||||||
|
|
||||||
let mut repositories = Vec::new();
|
|
||||||
for gitea in self.app.config.providers.gitea.iter() {
|
|
||||||
if let Some(_user) = &gitea.current_user {
|
|
||||||
let mut repos = gitea_provider
|
|
||||||
.list_repositories_for_current_user(&gitea.url, gitea.access_token.as_ref())
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
repositories.append(&mut repos);
|
|
||||||
}
|
|
||||||
|
|
||||||
for gitea_user in gitea.users.iter() {
|
|
||||||
let mut repos = gitea_provider
|
|
||||||
.list_repositories_for_user(
|
|
||||||
gitea_user.into(),
|
|
||||||
&gitea.url,
|
|
||||||
gitea.access_token.as_ref(),
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
repositories.append(&mut repos);
|
|
||||||
}
|
|
||||||
|
|
||||||
for gitea_org in gitea.organisations.iter() {
|
|
||||||
let mut repos = gitea_provider
|
|
||||||
.list_repositories_for_organisation(
|
|
||||||
gitea_org.into(),
|
|
||||||
&gitea.url,
|
|
||||||
gitea.access_token.as_ref(),
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
repositories.append(&mut repos);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(repositories)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn get_github_projects(&self) -> anyhow::Result<Vec<Repository>> {
|
|
||||||
let github_provider = self.app.github_provider();
|
|
||||||
|
|
||||||
let mut repositories = Vec::new();
|
|
||||||
for github in self.app.config.providers.github.iter() {
|
|
||||||
if let Some(_user) = &github.current_user {
|
|
||||||
let mut repos = github_provider
|
|
||||||
.list_repositories_for_current_user(
|
|
||||||
github.url.as_ref(),
|
|
||||||
&github.access_token,
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
repositories.append(&mut repos);
|
|
||||||
}
|
|
||||||
|
|
||||||
for github_user in github.users.iter() {
|
|
||||||
let mut repos = github_provider
|
|
||||||
.list_repositories_for_user(
|
|
||||||
github_user.into(),
|
|
||||||
github.url.as_ref(),
|
|
||||||
&github.access_token,
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
repositories.append(&mut repos);
|
|
||||||
}
|
|
||||||
|
|
||||||
for github_org in github.organisations.iter() {
|
|
||||||
let mut repos = github_provider
|
|
||||||
.list_repositories_for_organisation(
|
|
||||||
github_org.into(),
|
|
||||||
github.url.as_ref(),
|
|
||||||
&github.access_token,
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
repositories.append(&mut repos);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(repositories)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(feature = "example")]
|
|
||||||
mod example_projects;
|
|
@ -1,84 +0,0 @@
|
|||||||
use crate::{app::App, git_provider::Repository};
|
|
||||||
|
|
||||||
pub struct ProjectsList {}
|
|
||||||
|
|
||||||
impl ProjectsList {
|
|
||||||
pub fn new(_app: &'static App) -> Self {
|
|
||||||
Self {}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn get_projects(&self) -> anyhow::Result<Vec<Repository>> {
|
|
||||||
Ok(self.from_strings([
|
|
||||||
"github.com/kjuulh/gitnow",
|
|
||||||
"github.com/kjuulh/gitnow-client",
|
|
||||||
"github.com/kjuulh/crunch",
|
|
||||||
"git.front.kjuulh.io/kjuulh/gitnow",
|
|
||||||
"git.front.kjuulh.io/kjuulh/gitnow-client",
|
|
||||||
"git.front.kjuulh.io/kjuulh/cuddle",
|
|
||||||
"git.front.kjuulh.io/kjuulh/buckle",
|
|
||||||
"git.front.kjuulh.io/kjuulh/books",
|
|
||||||
"git.front.kjuulh.io/kjuulh/blog-deployment",
|
|
||||||
"git.front.kjuulh.io/kjuulh/blog",
|
|
||||||
"git.front.kjuulh.io/kjuulh/bitfield",
|
|
||||||
"git.front.kjuulh.io/kjuulh/bitebuds-deployment",
|
|
||||||
"git.front.kjuulh.io/kjuulh/bitebuds",
|
|
||||||
"git.front.kjuulh.io/kjuulh/beerday",
|
|
||||||
"git.front.kjuulh.io/kjuulh/bearing",
|
|
||||||
"git.front.kjuulh.io/kjuulh/basic-webserver",
|
|
||||||
"git.front.kjuulh.io/kjuulh/backup",
|
|
||||||
"git.front.kjuulh.io/kjuulh/backstage",
|
|
||||||
"git.front.kjuulh.io/kjuulh/autom8-calendar-integration",
|
|
||||||
"git.front.kjuulh.io/kjuulh/astronvim",
|
|
||||||
"git.front.kjuulh.io/kjuulh/artifacts",
|
|
||||||
"git.front.kjuulh.io/kjuulh/articles",
|
|
||||||
"git.front.kjuulh.io/kjuulh/acc-server",
|
|
||||||
"git.front.kjuulh.io/kjuulh/_cargo-index",
|
|
||||||
"git.front.kjuulh.io/keep-up/keep-up-example",
|
|
||||||
"git.front.kjuulh.io/keep-up/keep-up",
|
|
||||||
"git.front.kjuulh.io/experiments/wasm-bin",
|
|
||||||
"git.front.kjuulh.io/dotfiles/doom",
|
|
||||||
"git.front.kjuulh.io/danskebank/testssl.sh",
|
|
||||||
"git.front.kjuulh.io/clank/kubernetes-state",
|
|
||||||
"git.front.kjuulh.io/clank/kubernetes-init",
|
|
||||||
"git.front.kjuulh.io/clank/blog",
|
|
||||||
"git.front.kjuulh.io/cibus/deployments",
|
|
||||||
"git.front.kjuulh.io/butikkaerlighilsen/client",
|
|
||||||
"git.front.kjuulh.io/bevy/bevy",
|
|
||||||
"git.front.kjuulh.io/OpenFood/openfood",
|
|
||||||
]))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn from_strings(
|
|
||||||
&self,
|
|
||||||
repos_into: impl IntoIterator<Item = impl Into<Repository>>,
|
|
||||||
) -> Vec<Repository> {
|
|
||||||
let repos = repos_into
|
|
||||||
.into_iter()
|
|
||||||
.map(|item| item.into())
|
|
||||||
.collect::<Vec<Repository>>();
|
|
||||||
|
|
||||||
repos
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<&str> for Repository {
|
|
||||||
fn from(value: &str) -> Self {
|
|
||||||
let values = value.split("/").collect::<Vec<_>>();
|
|
||||||
if values.len() != 3 {
|
|
||||||
panic!("value: '{value}' isn't a valid provider/owner/repository")
|
|
||||||
}
|
|
||||||
|
|
||||||
let (provider, owner, name) = (
|
|
||||||
values.get(0).unwrap(),
|
|
||||||
values.get(1).unwrap(),
|
|
||||||
values.get(2).unwrap(),
|
|
||||||
);
|
|
||||||
|
|
||||||
Self {
|
|
||||||
provider: provider.to_string(),
|
|
||||||
owner: owner.to_string(),
|
|
||||||
repo_name: name.to_string(),
|
|
||||||
ssh_url: format!("ssh://git@{provider}/{owner}/{name}.git"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,65 +0,0 @@
|
|||||||
use anyhow::Context;
|
|
||||||
|
|
||||||
use crate::{app::App, git_provider::Repository};
|
|
||||||
|
|
||||||
pub struct Shell {
|
|
||||||
app: &'static App,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Shell {
|
|
||||||
pub fn new(app: &'static App) -> Self {
|
|
||||||
Self { app }
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn spawn_shell(&self, repository: &Repository) -> anyhow::Result<()> {
|
|
||||||
let project_path = self
|
|
||||||
.app
|
|
||||||
.config
|
|
||||||
.settings
|
|
||||||
.projects
|
|
||||||
.directory
|
|
||||||
.join(repository.to_rel_path());
|
|
||||||
|
|
||||||
if !project_path.exists() {
|
|
||||||
anyhow::bail!(
|
|
||||||
"project path: {} does not exists, it is either a file, or hasn't been cloned",
|
|
||||||
project_path.display()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
let shell = std::env::var("SHELL")
|
|
||||||
.context("failed to find SHELL variable, required for spawning embedded shells")?;
|
|
||||||
|
|
||||||
let mut shell_cmd = tokio::process::Command::new(shell);
|
|
||||||
shell_cmd.current_dir(project_path);
|
|
||||||
|
|
||||||
let mut process = shell_cmd.spawn().context("failed to spawn child session")?;
|
|
||||||
|
|
||||||
let status = process.wait().await?;
|
|
||||||
|
|
||||||
if !status.success() {
|
|
||||||
tracing::warn!(
|
|
||||||
"child session returned non-zero, or missing return code: {}",
|
|
||||||
status.code().unwrap_or_default()
|
|
||||||
);
|
|
||||||
anyhow::bail!(
|
|
||||||
"child shell session failed with exit: {}",
|
|
||||||
status.code().unwrap_or(-1)
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
tracing::debug!("child session returned 0 exit code");
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub trait ShellApp {
|
|
||||||
fn shell(&self) -> Shell;
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ShellApp for &'static App {
|
|
||||||
fn shell(&self) -> Shell {
|
|
||||||
Shell::new(self)
|
|
||||||
}
|
|
||||||
}
|
|
10
cuddle.yaml
10
cuddle.yaml
@ -13,13 +13,3 @@ please:
|
|||||||
branch: "main"
|
branch: "main"
|
||||||
settings:
|
settings:
|
||||||
api_url: "https://git.front.kjuulh.io"
|
api_url: "https://git.front.kjuulh.io"
|
||||||
actions:
|
|
||||||
rust:
|
|
||||||
|
|
||||||
scripts:
|
|
||||||
record:
|
|
||||||
type: shell
|
|
||||||
update-gifs:
|
|
||||||
type: shell
|
|
||||||
install:
|
|
||||||
type: shell
|
|
||||||
|
@ -1,7 +0,0 @@
|
|||||||
[[providers.github]]
|
|
||||||
users = ["kjuulh"]
|
|
||||||
organisations = ["lunarway"]
|
|
||||||
|
|
||||||
[[providers.gitea]]
|
|
||||||
users = ["kjuulh"]
|
|
||||||
organisation = ["noorgplease"]
|
|
@ -1,5 +0,0 @@
|
|||||||
#!/usr/bin/env zsh
|
|
||||||
|
|
||||||
set -e
|
|
||||||
|
|
||||||
cargo install --path crates/gitnow
|
|
@ -1,13 +0,0 @@
|
|||||||
#!/usr/bin/env zsh
|
|
||||||
|
|
||||||
set -e
|
|
||||||
|
|
||||||
# Loop through each file in the folder
|
|
||||||
for file in "vhs"/*; do
|
|
||||||
# Check if it is a file (not a directory)
|
|
||||||
if [[ -f "$file" ]]; then
|
|
||||||
echo "Recording: $file"
|
|
||||||
|
|
||||||
vhs "./$file"
|
|
||||||
fi
|
|
||||||
done
|
|
@ -1,11 +0,0 @@
|
|||||||
#!/usr/bin/env zsh
|
|
||||||
|
|
||||||
rm -r assets/gifs
|
|
||||||
|
|
||||||
set -e
|
|
||||||
|
|
||||||
cargo build --features example && clear
|
|
||||||
|
|
||||||
cuddle x record
|
|
||||||
mkdir -p assets/gifs
|
|
||||||
mv target/vhs/* assets/gifs
|
|
15
templates/docker-compose.yaml
Normal file
15
templates/docker-compose.yaml
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
version: "3"
|
||||||
|
services:
|
||||||
|
crdb:
|
||||||
|
restart: 'always'
|
||||||
|
image: 'cockroachdb/cockroach:v23.1.14'
|
||||||
|
command: 'start-single-node --advertise-addr 0.0.0.0 --insecure'
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-f", "http://localhost:8080/health?ready=1"]
|
||||||
|
interval: '10s'
|
||||||
|
timeout: '30s'
|
||||||
|
retries: 5
|
||||||
|
start_period: '20s'
|
||||||
|
ports:
|
||||||
|
- 8080:8080
|
||||||
|
- '26257:26257'
|
@ -1,17 +0,0 @@
|
|||||||
Output "target/vhs/example.gif"
|
|
||||||
Set Theme "Dracula"
|
|
||||||
Set Width 1200
|
|
||||||
Set Height 1000
|
|
||||||
Hide
|
|
||||||
Type "./target/debug/gitnow --no-cache --force-refresh"
|
|
||||||
Enter
|
|
||||||
Show
|
|
||||||
Sleep 2s
|
|
||||||
Type@500ms "bevy"
|
|
||||||
Sleep 1s
|
|
||||||
Enter
|
|
||||||
Sleep 10s
|
|
||||||
Type "echo 'I am now in bevy!'"
|
|
||||||
Enter
|
|
||||||
Sleep 5s
|
|
||||||
Sleep 2s
|
|
Loading…
Reference in New Issue
Block a user