├── .gitignore ├── .github ├── CODEOWNERS ├── dependabot.yml └── workflows │ └── rust.yml ├── src ├── cmd │ ├── cd.rs │ ├── version.rs │ ├── sync │ │ ├── dump.rs │ │ ├── mod.rs │ │ └── restore.rs │ ├── profile │ │ ├── mod.rs │ │ ├── show.rs │ │ ├── apply.rs │ │ └── list.rs │ ├── open.rs │ ├── list.rs │ ├── search.rs │ ├── shell.rs │ ├── delete.rs │ ├── path.rs │ ├── init.rs │ ├── mod.rs │ ├── add.rs │ ├── browse.rs │ └── clone.rs ├── git │ ├── config.rs │ ├── mod.rs │ ├── strategy │ │ ├── mod.rs │ │ └── cli.rs │ └── exclude.rs ├── main.rs ├── rule.rs ├── root.rs ├── repository.rs ├── config.rs ├── platform │ ├── mod.rs │ └── github.rs ├── application.rs ├── path.rs ├── console.rs ├── sync.rs ├── profile.rs └── url.rs ├── resources ├── scoop │ └── ghr.json.mustache ├── shell │ ├── fish │ │ ├── ghr.fish │ │ └── ghr-completion.fish │ └── bash │ │ ├── ghr.bash │ │ └── ghr-completion.bash └── formula │ └── ghr.rb.mustache ├── Dockerfile ├── LICENCE.md ├── Cargo.toml ├── ghr.example.toml └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @siketyan 2 | -------------------------------------------------------------------------------- /src/cmd/cd.rs: -------------------------------------------------------------------------------- 1 | use anyhow::{Result, bail}; 2 | use clap::Parser; 3 | 4 | #[derive(Debug, Parser)] 5 | pub struct Cmd { 6 | /// URL or pattern of the repository where to change directory to. 7 | repo: String, 8 | } 9 | 10 | impl Cmd { 11 | pub fn run(self) -> Result<()> { 12 | bail!("Shell extension is not configured correctly.") 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/git/config.rs: -------------------------------------------------------------------------------- 1 | use serde::Deserialize; 2 | 3 | use crate::git::strategy::Strategy; 4 | 5 | #[derive(Debug, Default, Deserialize)] 6 | pub struct StrategyConfig { 7 | #[serde(default)] 8 | pub clone: Strategy, 9 | #[serde(default)] 10 | pub fetch: Strategy, 11 | #[serde(default)] 12 | pub checkout: Strategy, 13 | } 14 | 15 | #[derive(Debug, Default, Deserialize)] 16 | pub struct Config { 17 | #[serde(default)] 18 | pub strategy: StrategyConfig, 19 | } 20 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | updates: 4 | - package-ecosystem: cargo 5 | directory: '/' 6 | schedule: 7 | interval: weekly 8 | groups: 9 | build-info: 10 | patterns: 11 | - build-info 12 | - build-info-build 13 | 14 | - package-ecosystem: docker 15 | directory: '/' 16 | schedule: 17 | interval: weekly 18 | 19 | - package-ecosystem: github-actions 20 | directory: '/' 21 | schedule: 22 | interval: weekly 23 | -------------------------------------------------------------------------------- /src/cmd/version.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use clap::Parser; 3 | 4 | use crate::{BUILD_INFO, VERSION}; 5 | 6 | #[derive(Debug, Parser)] 7 | pub struct Cmd { 8 | #[clap(short, long)] 9 | short: bool, 10 | } 11 | 12 | impl Cmd { 13 | pub fn run(self) -> Result<()> { 14 | println!( 15 | "{}", 16 | match self.short { 17 | true => VERSION, 18 | _ => BUILD_INFO, 19 | }, 20 | ); 21 | 22 | Ok(()) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /resources/scoop/ghr.json.mustache: -------------------------------------------------------------------------------- 1 | { 2 | "version": "{{tag_without_v}}", 3 | "description": "Yet another repository management with auto-attaching profiles", 4 | "homepage": "https://github.com/siketyan/ghr", 5 | "license": { 6 | "identifier": "MIT", 7 | "url": "https://github.com/siketyan/ghr/blob/main/LICENCE.md" 8 | }, 9 | "architecture": { 10 | "64bit": { 11 | "url": "{{{assets.windows_x86_64.browser_download_url}}}", 12 | "hash": "{{assets.windows_x86_64.hash}}" 13 | } 14 | }, 15 | "bin": "ghr.exe" 16 | } 17 | -------------------------------------------------------------------------------- /src/cmd/sync/dump.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use clap::Parser; 3 | use itertools::Itertools; 4 | 5 | use crate::repository::Repositories; 6 | use crate::root::Root; 7 | use crate::sync::File; 8 | 9 | #[derive(Debug, Parser)] 10 | pub struct Cmd {} 11 | 12 | impl Cmd { 13 | pub fn run(self) -> Result<()> { 14 | let root = Root::find()?; 15 | let file = Repositories::try_collect(&root)? 16 | .into_iter() 17 | .map(|(p, _)| p) 18 | .sorted_by_key(|p| p.to_string()) 19 | .collect::(); 20 | 21 | println!("{}", toml::to_string(&file)?); 22 | 23 | Ok(()) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # syntax = docker/dockerfile:1.4 2 | 3 | FROM rust:1.92 AS base 4 | SHELL ["/bin/bash", "-c"] 5 | WORKDIR /src 6 | 7 | RUN cargo install cargo-chef 8 | 9 | # --- 10 | 11 | FROM base as planner 12 | COPY . . 13 | RUN cargo chef prepare --recipe-path /recipe.json 14 | 15 | # --- 16 | 17 | FROM base as builder 18 | SHELL ["/bin/bash", "-c"] 19 | 20 | COPY --from=planner /recipe.json . 21 | RUN cargo chef cook --release --recipe-path recipe.json --bin ghr 22 | 23 | COPY . . 24 | RUN cargo build --release --bin ghr 25 | 26 | # --- 27 | 28 | FROM gcr.io/distroless/cc 29 | COPY --from=builder /src/target/release/ghr /bin/ghr 30 | ENTRYPOINT ["/bin/ghr"] 31 | 32 | -------------------------------------------------------------------------------- /src/cmd/sync/mod.rs: -------------------------------------------------------------------------------- 1 | mod dump; 2 | mod restore; 3 | 4 | use anyhow::Result; 5 | use clap::{Parser, Subcommand}; 6 | 7 | #[derive(Debug, Subcommand)] 8 | pub enum Action { 9 | /// Dump remotes and the current ref of all repositories. 10 | Dump(dump::Cmd), 11 | /// Restore repositories from the dumped file. 12 | Restore(restore::Cmd), 13 | } 14 | 15 | #[derive(Debug, Parser)] 16 | pub struct Cmd { 17 | #[clap(subcommand)] 18 | action: Action, 19 | } 20 | 21 | impl Cmd { 22 | pub async fn run(self) -> Result<()> { 23 | use Action::*; 24 | match self.action { 25 | Dump(cmd) => cmd.run(), 26 | Restore(cmd) => cmd.run().await, 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/cmd/profile/mod.rs: -------------------------------------------------------------------------------- 1 | mod apply; 2 | mod list; 3 | mod show; 4 | 5 | use anyhow::Result; 6 | use clap::{Parser, Subcommand}; 7 | 8 | #[derive(Debug, Subcommand)] 9 | pub enum Action { 10 | /// Lists all configured profiles. 11 | List(list::Cmd), 12 | /// Shows a profile in TOML format. 13 | Show(show::Cmd), 14 | /// Apply a profile. 15 | Apply(apply::Cmd), 16 | } 17 | 18 | #[derive(Debug, Parser)] 19 | pub struct Cmd { 20 | #[clap(subcommand)] 21 | action: Action, 22 | } 23 | 24 | impl Cmd { 25 | pub fn run(self) -> Result<()> { 26 | use Action::*; 27 | match self.action { 28 | List(cmd) => cmd.run(), 29 | Show(cmd) => cmd.run(), 30 | Apply(cmd) => cmd.run(), 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | mod application; 2 | mod cmd; 3 | mod config; 4 | mod console; 5 | mod git; 6 | mod path; 7 | mod platform; 8 | mod profile; 9 | mod repository; 10 | mod root; 11 | mod rule; 12 | mod sync; 13 | mod url; 14 | 15 | use std::process::exit; 16 | 17 | use clap::Parser; 18 | use tracing::error; 19 | 20 | use crate::cmd::Cli; 21 | 22 | const BUILD_INFO: &str = build_info::format!( 23 | "{} v{} built with {} at {}", 24 | $.crate_info.name, 25 | $.crate_info.version, 26 | $.compiler, 27 | $.timestamp, 28 | ); 29 | 30 | const VERSION: &str = build_info::format!("{}", $.crate_info.version); 31 | 32 | #[tokio::main] 33 | async fn main() { 34 | if let Err(e) = Cli::parse().run().await { 35 | error!("{}", e); 36 | exit(1); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/cmd/profile/show.rs: -------------------------------------------------------------------------------- 1 | use anyhow::{Result, anyhow}; 2 | use clap::Parser; 3 | use itertools::Itertools; 4 | 5 | use crate::config::Config; 6 | 7 | #[derive(Debug, Parser)] 8 | pub struct Cmd { 9 | // Name of the profile to show. 10 | name: String, 11 | } 12 | 13 | impl Cmd { 14 | pub fn run(self) -> Result<()> { 15 | let config = Config::load()?; 16 | let profile = config 17 | .profiles 18 | .get(&self.name) 19 | .ok_or_else(|| anyhow!("Unknown profile: {}", &self.name))?; 20 | 21 | profile 22 | .configs 23 | .iter() 24 | .sorted_by_key(|(k, _)| k.to_string()) 25 | .for_each(|(k, v)| { 26 | println!(r#"{} = "{}""#, k, v); 27 | }); 28 | 29 | Ok(()) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/cmd/profile/apply.rs: -------------------------------------------------------------------------------- 1 | use anyhow::{Result, anyhow}; 2 | use clap::Parser; 3 | use console::style; 4 | use git2::Repository; 5 | use tracing::info; 6 | 7 | use crate::config::Config; 8 | 9 | #[derive(Debug, Parser)] 10 | pub struct Cmd { 11 | // Name of the profile to apply. 12 | name: String, 13 | } 14 | 15 | impl Cmd { 16 | pub fn run(self) -> Result<()> { 17 | let config = Config::load()?; 18 | let profile = config 19 | .profiles 20 | .get(&self.name) 21 | .ok_or_else(|| anyhow!("Unknown profile: {}", &self.name))?; 22 | 23 | let repo = Repository::open_from_env()?; 24 | profile.apply(&repo)?; 25 | 26 | info!( 27 | "Attached profile [{}] successfully.", 28 | style(self.name).bold() 29 | ); 30 | 31 | Ok(()) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /resources/shell/fish/ghr.fish: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env fish 2 | 3 | function __ghr_remove 4 | for VAR in $argv[2..] 5 | if test "$VAR" = "$argv[1]" 6 | continue 7 | end 8 | 9 | echo "$VAR" 10 | end 11 | end 12 | 13 | function __ghr_cd 14 | cd "$(ghr path $argv)" 15 | end 16 | 17 | function ghr 18 | if contains -- "--help" $argv[1..]; or contains -- "-h" $argv[1..] 19 | command ghr $argv[1..] 20 | return 0 21 | end 22 | 23 | if test "$argv[1]" = "cd" 24 | __ghr_cd $argv[2..] 25 | return 0 26 | end 27 | 28 | if test "$argv[1]" = "clone" || test "$argv[1]" = "init" 29 | if contains -- "--cd" $argv[2..] 30 | command ghr "$argv[1]" $argv[2..] && __ghr_cd (__ghr_remove "--cd" $argv[2..]) 31 | return 0 32 | end 33 | end 34 | 35 | command ghr $argv[1..] 36 | end 37 | -------------------------------------------------------------------------------- /src/cmd/open.rs: -------------------------------------------------------------------------------- 1 | use std::path::PathBuf; 2 | 3 | use anyhow::Result; 4 | use clap::Parser; 5 | 6 | use crate::config::Config; 7 | use crate::path::Path; 8 | use crate::root::Root; 9 | use crate::url::Url; 10 | 11 | #[derive(Debug, Parser)] 12 | pub struct Cmd { 13 | /// URL or pattern of the repository to open application in. 14 | repo: String, 15 | 16 | /// Name of the application entry. 17 | application: Option, 18 | } 19 | 20 | impl Cmd { 21 | pub fn run(self) -> Result<()> { 22 | let root = Root::find()?; 23 | let config = Config::load_from(&root)?; 24 | 25 | let url = Url::from_str( 26 | &self.repo, 27 | &config.patterns, 28 | config.defaults.owner.as_deref(), 29 | )?; 30 | let path = PathBuf::from(Path::resolve(&root, &url)); 31 | 32 | config 33 | .applications 34 | .open_or_intermediate_or_default(self.application.as_deref(), path)?; 35 | 36 | Ok(()) 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/git/mod.rs: -------------------------------------------------------------------------------- 1 | mod config; 2 | mod strategy; 3 | 4 | pub mod exclude; 5 | 6 | pub use config::Config; 7 | 8 | use std::path::Path; 9 | 10 | use anyhow::Result; 11 | 12 | #[derive(Debug, Default)] 13 | pub struct CloneOptions { 14 | pub recursive: Option>, 15 | pub single_branch: bool, 16 | pub origin: Option, 17 | pub branch: Option, 18 | } 19 | 20 | pub trait CloneRepository { 21 | fn clone_repository(&self, url: U, path: P, options: &CloneOptions) -> Result<()> 22 | where 23 | U: ToString, 24 | P: AsRef; 25 | } 26 | 27 | pub trait Fetch { 28 | fn fetch

(&self, path: P, remote: impl Into) -> Result<()> 29 | where 30 | P: AsRef; 31 | } 32 | 33 | pub trait CheckoutBranch { 34 | fn checkout_branch

( 35 | &self, 36 | path: P, 37 | branch: impl Into, 38 | track: impl Into>, 39 | ) -> Result<()> 40 | where 41 | P: AsRef; 42 | } 43 | -------------------------------------------------------------------------------- /resources/formula/ghr.rb.mustache: -------------------------------------------------------------------------------- 1 | class Ghr < Formula 2 | desc "Yet another repository management with auto-attaching profiles" 3 | homepage "https://github.com/siketyan/ghr" 4 | version "{{tag_without_v}}" 5 | license "MIT" 6 | 7 | on_macos do 8 | if Hardware::CPU.intel? 9 | url "{{{assets.macos_x86_64.browser_download_url}}}" 10 | sha256 "{{assets.macos_x86_64.hash}}" 11 | else 12 | url "{{{assets.macos_aarch64.browser_download_url}}}" 13 | sha256 "{{assets.macos_aarch64.hash}}" 14 | end 15 | 16 | def install 17 | bin.install "ghr" 18 | end 19 | end 20 | 21 | on_linux do 22 | if Hardware::CPU.intel? 23 | url "{{{assets.linux_x86_64.browser_download_url}}}" 24 | sha256 "{{assets.linux_x86_64.hash}}" 25 | else 26 | url "{{{assets.linux_aarch64.browser_download_url}}}" 27 | sha256 "{{assets.linux_aarch64.hash}}" 28 | end 29 | 30 | def install 31 | bin.install "ghr" 32 | end 33 | end 34 | 35 | test do 36 | system "#{bin}/ghr", "version" 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /src/rule.rs: -------------------------------------------------------------------------------- 1 | use crate::url::Url; 2 | use serde::Deserialize; 3 | 4 | #[derive(Debug, Clone, Deserialize)] 5 | pub struct ProfileRef { 6 | pub name: String, 7 | } 8 | 9 | #[derive(Debug, Deserialize)] 10 | pub struct Rule { 11 | pub profile: ProfileRef, 12 | pub host: Option, 13 | pub owner: Option, 14 | pub repo: Option, 15 | } 16 | 17 | impl Rule { 18 | pub fn matches(&self, url: &Url) -> bool { 19 | self.host 20 | .as_deref() 21 | .map(|h| h == url.host.to_string()) 22 | .unwrap_or(true) 23 | && self 24 | .owner 25 | .as_deref() 26 | .map(|o| o == url.owner) 27 | .unwrap_or(true) 28 | && self.repo.as_deref().map(|r| r == url.repo).unwrap_or(true) 29 | } 30 | } 31 | 32 | #[derive(Debug, Default, Deserialize)] 33 | pub struct Rules(Vec); 34 | 35 | impl Rules { 36 | pub fn resolve(&self, url: &Url) -> Option<&Rule> { 37 | self.0.iter().find(|rule| rule.matches(url)) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/root.rs: -------------------------------------------------------------------------------- 1 | use std::env::var; 2 | use std::path::PathBuf; 3 | use std::str::FromStr; 4 | 5 | use anyhow::{Result, anyhow}; 6 | use dirs::home_dir; 7 | use tracing::debug; 8 | 9 | const ENV_VAR_KEY: &str = "GHR_ROOT"; 10 | const DEFAULT_ROOT_NAME: &str = ".ghr"; 11 | 12 | #[derive(Debug, Eq, PartialEq, Hash)] 13 | pub struct Root { 14 | path: PathBuf, 15 | } 16 | 17 | impl Root { 18 | pub fn find() -> Result { 19 | let path = match var(ENV_VAR_KEY).ok().and_then(|s| match s.is_empty() { 20 | true => None, 21 | _ => Some(s), 22 | }) { 23 | Some(p) => PathBuf::from_str(&p)?.canonicalize()?, 24 | _ => home_dir() 25 | .ok_or_else(|| anyhow!("Could not find a home directory"))? 26 | .join(DEFAULT_ROOT_NAME), 27 | }; 28 | 29 | debug!( 30 | "Found a root directory: {}", 31 | path.to_str().unwrap_or_default(), 32 | ); 33 | 34 | Ok(Self { path }) 35 | } 36 | 37 | pub fn path(&self) -> &PathBuf { 38 | &self.path 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/cmd/list.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use clap::Parser; 3 | use itertools::Itertools; 4 | use std::path::PathBuf; 5 | 6 | use crate::repository::Repositories; 7 | use crate::root::Root; 8 | 9 | #[derive(Debug, Parser)] 10 | pub struct Cmd { 11 | /// Lists repositories without their hosts. 12 | #[clap(long)] 13 | no_host: bool, 14 | 15 | /// Lists repositories without their owners. 16 | #[clap(long)] 17 | no_owner: bool, 18 | 19 | /// Lists repositories as full paths instead of their names. 20 | #[clap(short, long)] 21 | path: bool, 22 | } 23 | 24 | impl Cmd { 25 | pub fn run(self) -> Result<()> { 26 | let root = Root::find()?; 27 | 28 | Repositories::try_collect(&root)? 29 | .into_iter() 30 | .map(|(path, _)| match self.path { 31 | true => PathBuf::from(path).to_string_lossy().to_string(), 32 | _ => path.to_string_with(!self.no_host, !self.no_owner), 33 | }) 34 | .sorted() 35 | .for_each(|path| println!("{}", path)); 36 | 37 | Ok(()) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /resources/shell/bash/ghr.bash: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | __GHR=$(which ghr | head -n 1) 4 | 5 | __ghr_cd() { 6 | cd "$($__GHR path $@)" 7 | } 8 | 9 | __ghr_contains() { 10 | for VAR in ${@:2}; do 11 | if [ "$VAR" = "$1" ]; then 12 | return 0 13 | fi 14 | done 15 | 16 | return 1 17 | } 18 | 19 | __ghr_remove() { 20 | for VAR in ${@:2}; do 21 | if [ "$VAR" = "$1" ]; then 22 | continue 23 | fi 24 | 25 | echo "$VAR" 26 | done 27 | } 28 | 29 | ghr() { 30 | local arg 31 | 32 | for arg in "$@"; do 33 | if [ "$arg" = '-h' ] || [ "$arg" = '--help' ]; then 34 | $__GHR $@ 35 | return 36 | fi 37 | done 38 | 39 | if [ "$#" -gt 1 ]; then 40 | if [ "$1" = "cd" ]; then 41 | __ghr_cd ${@:2} 42 | return 43 | fi 44 | 45 | if { [ "$1" = "clone" ] || [ "$1" = "init" ]; } && __ghr_contains "--cd" ${@:2}; then 46 | $__GHR "$1" ${@:2} 47 | __ghr_cd "$(__ghr_remove "--cd" ${@:2})" 48 | return 49 | fi 50 | fi 51 | 52 | $__GHR $@ 53 | } 54 | -------------------------------------------------------------------------------- /LICENCE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Naoki Ikeguchi 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/cmd/search.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use clap::Parser; 3 | use itertools::Itertools; 4 | use nucleo_matcher::pattern::{AtomKind, CaseMatching, Normalization, Pattern}; 5 | use nucleo_matcher::{Config, Matcher}; 6 | 7 | use crate::repository::Repositories; 8 | use crate::root::Root; 9 | 10 | const MIN_SCORE: u32 = 50; 11 | 12 | #[derive(Debug, Parser)] 13 | pub struct Cmd { 14 | query: String, 15 | } 16 | 17 | impl Cmd { 18 | pub fn run(self) -> Result<()> { 19 | let root = Root::find()?; 20 | 21 | let mut matcher = Matcher::new(Config::DEFAULT); 22 | let pattern = Pattern::new( 23 | &self.query, 24 | CaseMatching::Smart, 25 | Normalization::Smart, 26 | AtomKind::Fuzzy, 27 | ); 28 | 29 | let matches = pattern.match_list( 30 | Repositories::try_collect(&root)? 31 | .into_iter() 32 | .map(|(path, _)| path.to_string()), 33 | &mut matcher, 34 | ); 35 | 36 | matches 37 | .into_iter() 38 | .filter(|(_, score)| *score > MIN_SCORE) 39 | .sorted_by_key(|(_, score)| -i64::from(*score)) 40 | .for_each(|(path, _)| println!("{}", path)); 41 | 42 | Ok(()) 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/git/strategy/mod.rs: -------------------------------------------------------------------------------- 1 | mod cli; 2 | 3 | pub use cli::Cli; 4 | 5 | use std::path::Path; 6 | 7 | use serde::Deserialize; 8 | 9 | use crate::git::{CheckoutBranch, CloneOptions, CloneRepository, Fetch}; 10 | 11 | #[derive(Debug, Default, Deserialize)] 12 | pub enum Strategy { 13 | #[default] 14 | Cli, 15 | } 16 | 17 | impl CloneRepository for Strategy { 18 | fn clone_repository(&self, url: U, path: P, options: &CloneOptions) -> anyhow::Result<()> 19 | where 20 | U: ToString, 21 | P: AsRef, 22 | { 23 | match self { 24 | Self::Cli => Cli.clone_repository(url, path, options), 25 | } 26 | } 27 | } 28 | 29 | impl Fetch for Strategy { 30 | fn fetch

(&self, path: P, remote: impl Into) -> anyhow::Result<()> 31 | where 32 | P: AsRef, 33 | { 34 | match self { 35 | Self::Cli => Cli.fetch(path, remote), 36 | } 37 | } 38 | } 39 | 40 | impl CheckoutBranch for Strategy { 41 | fn checkout_branch

( 42 | &self, 43 | path: P, 44 | branch: impl Into, 45 | track: impl Into>, 46 | ) -> anyhow::Result<()> 47 | where 48 | P: AsRef, 49 | { 50 | match self { 51 | Self::Cli => Cli.checkout_branch(path, branch, track), 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/cmd/profile/list.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use clap::Parser; 3 | use console::style; 4 | 5 | use crate::config::Config; 6 | 7 | const INHERIT: &str = "(inherit)"; 8 | 9 | #[derive(Debug, Parser)] 10 | pub struct Cmd { 11 | /// Shows only the name of the profiles 12 | #[clap(short, long)] 13 | short: bool, 14 | } 15 | 16 | impl Cmd { 17 | pub fn run(self) -> Result<()> { 18 | let config = Config::load()?; 19 | 20 | config.profiles.iter().for_each(|(name, profile)| { 21 | if self.short { 22 | println!("{}", name) 23 | } else { 24 | println!( 25 | " {} - {}: {} {}", 26 | style("OK").cyan(), 27 | style(name).bold(), 28 | profile 29 | .configs 30 | .get("user.name") 31 | .map(|name| name.as_str()) 32 | .unwrap_or(INHERIT), 33 | style(&format!( 34 | "<{}>", 35 | profile 36 | .configs 37 | .get("user.email") 38 | .map(|email| email.as_str()) 39 | .unwrap_or(INHERIT), 40 | )) 41 | .dim(), 42 | ); 43 | } 44 | }); 45 | 46 | Ok(()) 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/cmd/shell.rs: -------------------------------------------------------------------------------- 1 | use std::fmt::{Display, Formatter}; 2 | 3 | use anyhow::Result; 4 | use clap::{Parser, ValueEnum}; 5 | 6 | #[derive(Clone, Debug, Default, ValueEnum)] 7 | pub enum Kind { 8 | #[default] 9 | Bash, 10 | Fish, 11 | } 12 | 13 | impl Display for Kind { 14 | fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { 15 | write!( 16 | f, 17 | "{}", 18 | match self { 19 | Kind::Bash => "bash", 20 | Kind::Fish => "fish", 21 | }, 22 | ) 23 | } 24 | } 25 | 26 | #[derive(Debug, Parser)] 27 | pub struct Cmd { 28 | /// Kind of the shell. 29 | #[clap(default_value_t)] 30 | kind: Kind, 31 | 32 | /// Uses the shell completion 33 | #[clap(long)] 34 | completion: bool, 35 | } 36 | 37 | impl Cmd { 38 | pub fn run(self) -> Result<()> { 39 | let script = match self.kind { 40 | Kind::Bash => match self.completion { 41 | true => include_str!("../../resources/shell/bash/ghr-completion.bash"), 42 | _ => include_str!("../../resources/shell/bash/ghr.bash"), 43 | }, 44 | Kind::Fish => match self.completion { 45 | true => include_str!("../../resources/shell/fish/ghr-completion.fish"), 46 | _ => include_str!("../../resources/shell/fish/ghr.fish"), 47 | }, 48 | }; 49 | 50 | print!("{}", script); 51 | 52 | Ok(()) 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/cmd/delete.rs: -------------------------------------------------------------------------------- 1 | use std::future::ready; 2 | use std::path::PathBuf; 3 | 4 | use anyhow::Result; 5 | use clap::Parser; 6 | use console::style; 7 | use dialoguer::Confirm; 8 | use tracing::info; 9 | 10 | use crate::config::Config; 11 | use crate::console::Spinner; 12 | use crate::path::Path; 13 | use crate::root::Root; 14 | use crate::url::Url; 15 | 16 | #[derive(Debug, Parser)] 17 | pub struct Cmd { 18 | /// URL or pattern of the repository to delete. 19 | repo: Vec, 20 | } 21 | 22 | impl Cmd { 23 | pub async fn run(self) -> Result<()> { 24 | let root = Root::find()?; 25 | let config = Config::load_from(&root)?; 26 | 27 | if !Confirm::new() 28 | .with_prompt(format!( 29 | "{} Content of the repository will be deleted permanently. Are you sure want to continue?", 30 | style("CHECK").dim(), 31 | )) 32 | .interact()? 33 | { 34 | return Ok(()); 35 | } 36 | 37 | for repo in self.repo.iter() { 38 | let url = Url::from_str(repo, &config.patterns, config.defaults.owner.as_deref())?; 39 | let path = PathBuf::from(Path::resolve(&root, &url)); 40 | 41 | Spinner::new("Deleting the repository...") 42 | .spin_while(|| ready(std::fs::remove_dir_all(&path).map_err(anyhow::Error::from))) 43 | .await?; 44 | 45 | info!( 46 | "Deleted the repository successfully: {}", 47 | path.to_string_lossy(), 48 | ); 49 | } 50 | 51 | Ok(()) 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "ghr" 3 | description = "Yet another repository management with auto-attaching profiles." 4 | version = "0.4.5" 5 | license = "MIT" 6 | homepage = "https://github.com/siketyan/ghr" 7 | repository = "https://github.com/siketyan/ghr.git" 8 | readme = "README.md" 9 | rust-version = "1.85.0" 10 | edition = "2024" 11 | authors = [ 12 | "Naoki Ikeguchi ", 13 | ] 14 | 15 | [dependencies] 16 | anyhow = "1.0" 17 | async-hofs = "0.1.1" 18 | async-trait = "0.1.89" 19 | build-info = "0.0.41" 20 | clap = { version = "4.5", features = ["derive"] } 21 | console = "0.16.1" 22 | dialoguer = "0.12.0" 23 | dirs = "6.0" 24 | git2 = "0.20.3" 25 | itertools = "0.14.0" 26 | indicatif = "0.18.3" 27 | nucleo-matcher = "0.3.1" 28 | regex = "1.12" 29 | serde = { version = "1.0", features = ["derive"] } 30 | serde_regex = "1.1" 31 | serde_with = "3.16" 32 | tokio = { version = "1.48", features = ["macros", "rt-multi-thread"] } 33 | tokio-stream = "0.1.17" 34 | toml = "0.9.8" 35 | tracing = "0.1.43" 36 | tracing-subscriber = { version = "0.3.22", features = ["env-filter"] } 37 | url = "2.5" 38 | walkdir = "2.5" 39 | 40 | gh-config = { version = "0.5.1", optional = true } 41 | octocrab = { version = "0.47.1", optional = true } 42 | 43 | [target.'cfg(windows)'.dependencies.windows] 44 | version = "0.60.0" 45 | features = [ 46 | "Win32_UI_Shell", 47 | "Win32_Foundation", 48 | "Win32_UI_WindowsAndMessaging", 49 | ] 50 | 51 | [build-dependencies] 52 | build-info-build = "0.0.41" 53 | 54 | [features] 55 | default = ["github"] 56 | vendored = ["git2/vendored-libgit2", "git2/vendored-openssl"] 57 | github = ["gh-config", "octocrab"] 58 | -------------------------------------------------------------------------------- /src/repository.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | use std::path::PathBuf; 3 | 4 | use anyhow::Result; 5 | use itertools::Itertools; 6 | use walkdir::WalkDir; 7 | 8 | use crate::path::Path; 9 | use crate::root::Root; 10 | 11 | pub struct Repository { 12 | #[allow(dead_code)] 13 | path: PathBuf, 14 | } 15 | 16 | impl Repository { 17 | fn new

(path: P) -> Self 18 | where 19 | P: AsRef, 20 | { 21 | Self { 22 | path: PathBuf::from(path.as_ref()), 23 | } 24 | } 25 | } 26 | 27 | pub struct Repositories<'a> { 28 | map: HashMap, Repository>, 29 | } 30 | 31 | impl<'a> Repositories<'a> { 32 | pub fn try_collect(root: &'a Root) -> Result { 33 | Ok(Self { 34 | map: WalkDir::new(root.path()) 35 | .min_depth(3) 36 | .max_depth(3) 37 | .into_iter() 38 | .map_ok(|entry| entry.into_path()) 39 | .filter_ok(|path| path.is_dir()) 40 | .map_ok(|path| { 41 | let parts = path.strip_prefix(root.path())?.iter().collect::>(); 42 | 43 | Ok::<_, anyhow::Error>(( 44 | Path::new( 45 | root, 46 | parts[0].to_string_lossy(), 47 | parts[1].to_string_lossy(), 48 | parts[2].to_string_lossy(), 49 | ), 50 | Repository::new(path), 51 | )) 52 | }) 53 | .flatten() 54 | .try_collect()?, 55 | }) 56 | } 57 | } 58 | 59 | impl<'a> IntoIterator for Repositories<'a> { 60 | type Item = (Path<'a>, Repository); 61 | type IntoIter = std::collections::hash_map::IntoIter, Repository>; 62 | 63 | fn into_iter(self) -> Self::IntoIter { 64 | self.map.into_iter() 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/config.rs: -------------------------------------------------------------------------------- 1 | use std::fs::read_to_string; 2 | use std::path::Path; 3 | 4 | use anyhow::Result; 5 | use serde::Deserialize; 6 | 7 | use crate::application::Applications; 8 | use crate::git::Config as GitConfig; 9 | use crate::platform::Config as PlatformConfig; 10 | use crate::profile::Profiles; 11 | use crate::root::Root; 12 | use crate::rule::Rules; 13 | use crate::url::Patterns; 14 | 15 | #[derive(Debug, Default, Deserialize)] 16 | pub struct Defaults { 17 | pub owner: Option, 18 | } 19 | 20 | #[derive(Debug, Default, Deserialize)] 21 | pub struct Config { 22 | #[serde(default)] 23 | pub defaults: Defaults, 24 | #[serde(default)] 25 | pub git: GitConfig, 26 | #[serde(default)] 27 | pub platforms: PlatformConfig, 28 | #[serde(default)] 29 | pub patterns: Patterns, 30 | #[serde(default)] 31 | pub profiles: Profiles, 32 | #[serde(default)] 33 | pub applications: Applications, 34 | #[serde(default)] 35 | pub rules: Rules, 36 | } 37 | 38 | impl Config { 39 | pub fn load_from(root: &Root) -> Result { 40 | Ok(Self::load_from_path(root.path().join("ghr.toml"))?.unwrap_or_default()) 41 | } 42 | 43 | pub fn load() -> Result { 44 | Self::load_from(&Root::find()?) 45 | } 46 | 47 | fn load_from_path

(path: P) -> Result> 48 | where 49 | P: AsRef, 50 | { 51 | Ok(match path.as_ref().exists() { 52 | true => Some(Self::load_from_str(read_to_string(path)?.as_str())?), 53 | _ => None, 54 | }) 55 | } 56 | 57 | fn load_from_str(s: &str) -> Result { 58 | Ok(toml::from_str::(s)?.with_defaults()) 59 | } 60 | 61 | fn with_defaults(mut self) -> Self { 62 | self.patterns = self.patterns.with_defaults(); 63 | self 64 | } 65 | } 66 | 67 | #[cfg(test)] 68 | mod tests { 69 | use crate::config::Config; 70 | 71 | #[test] 72 | fn load_example_config() { 73 | Config::load_from_str(include_str!("../ghr.example.toml")).unwrap(); 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/cmd/path.rs: -------------------------------------------------------------------------------- 1 | use std::path::PathBuf; 2 | 3 | use anyhow::{Result, anyhow}; 4 | use clap::Parser; 5 | 6 | use crate::config::Config; 7 | use crate::path::{PartialPath, Path}; 8 | use crate::root::Root; 9 | use crate::url::{Host, Url}; 10 | 11 | #[derive(Debug, Parser)] 12 | pub struct Cmd { 13 | /// Shows the relative path from the root instead of absolute one. 14 | #[clap(short, long)] 15 | relative: bool, 16 | 17 | /// Remote host of the repository. 18 | /// Defaults to github.com. 19 | #[clap(long)] 20 | host: Option, 21 | 22 | /// Owner name of the repository. 23 | /// Defaults to the default owner if it is configured. 24 | #[clap(short, long)] 25 | owner: Option, 26 | 27 | /// Repository name. 28 | repo: Option, 29 | } 30 | 31 | impl Cmd { 32 | pub fn run(self) -> Result<()> { 33 | let root = Root::find()?; 34 | let config = Config::load_from(&root)?; 35 | 36 | let path = if let Some(repo) = self.repo.as_deref() { 37 | let url = Url::from_str( 38 | repo, 39 | &config.patterns, 40 | self.owner.as_deref().or(config.defaults.owner.as_deref()), 41 | )?; 42 | 43 | PathBuf::from(Path::resolve(&root, &url)) 44 | } else { 45 | PathBuf::from(PartialPath { 46 | root: &root, 47 | host: match self.owner.is_some() || self.repo.is_some() { 48 | true => self.host.or_else(|| Some(Host::GitHub.to_string())), 49 | _ => self.host, 50 | }, 51 | owner: self.owner, 52 | repo: None, 53 | }) 54 | }; 55 | 56 | if !path.exists() || !path.is_dir() { 57 | return Err(anyhow!( 58 | "The path does not exist or is not a directory. Did you cloned the repository?" 59 | )); 60 | } 61 | 62 | println!( 63 | "{}", 64 | match self.relative { 65 | true => path.strip_prefix(root.path())?.to_string_lossy(), 66 | _ => path.to_string_lossy(), 67 | }, 68 | ); 69 | 70 | Ok(()) 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/platform/mod.rs: -------------------------------------------------------------------------------- 1 | mod github; 2 | 3 | use std::result::Result as StdResult; 4 | 5 | use anyhow::Result; 6 | use async_trait::async_trait; 7 | use serde::Deserialize; 8 | use std::collections::HashMap; 9 | 10 | use crate::url::Url; 11 | 12 | #[async_trait] 13 | pub trait Fork { 14 | async fn fork(&self, url: &Url, owner: Option) -> Result; 15 | } 16 | 17 | #[async_trait] 18 | pub trait Browse { 19 | async fn get_browsable_url(&self, url: &Url) -> Result; 20 | } 21 | 22 | pub trait PlatformInit: Sized { 23 | type Config; 24 | 25 | fn init(config: &Self::Config) -> Result; 26 | } 27 | 28 | pub trait Platform: Fork + Browse {} 29 | 30 | #[derive(Debug, Deserialize)] 31 | #[serde(tag = "type")] 32 | pub enum PlatformConfig { 33 | #[cfg(feature = "github")] 34 | #[serde(rename = "github")] 35 | GitHub(github::Config), 36 | } 37 | 38 | impl PlatformConfig { 39 | pub fn try_into_platform(&self) -> Result> { 40 | self.try_into() 41 | } 42 | 43 | fn host(&self) -> String { 44 | match self { 45 | #[cfg(feature = "github")] 46 | Self::GitHub(c) => c.host.to_string(), 47 | } 48 | } 49 | } 50 | 51 | impl TryInto> for &PlatformConfig { 52 | type Error = anyhow::Error; 53 | 54 | fn try_into(self) -> StdResult, Self::Error> { 55 | Ok(match self { 56 | #[cfg(feature = "github")] 57 | PlatformConfig::GitHub(c) => Box::new(github::GitHub::init(c)?), 58 | }) 59 | } 60 | } 61 | 62 | #[derive(Debug, Deserialize)] 63 | pub struct Config { 64 | #[serde(flatten)] 65 | map: HashMap, 66 | } 67 | 68 | impl Default for Config { 69 | fn default() -> Self { 70 | Self { 71 | map: HashMap::from([ 72 | #[cfg(feature = "github")] 73 | ( 74 | "github".to_string(), 75 | PlatformConfig::GitHub(github::Config::default()), 76 | ), 77 | ]), 78 | } 79 | } 80 | } 81 | 82 | impl Config { 83 | pub fn find(&self, url: &Url) -> Option<&PlatformConfig> { 84 | self.map.values().find(|c| c.host() == url.host.to_string()) 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /ghr.example.toml: -------------------------------------------------------------------------------- 1 | # 2 | # Example configuration for ghr. 3 | # 4 | 5 | [defaults] 6 | # Sets the default owner of repositories. 7 | # You can pass only repository name to 'ghr clone' when this is set. 8 | owner = "siketyan" 9 | 10 | [git] 11 | # Chooses the strategy to use on Git clones. 12 | # 'Cli' is the default and only supported. 13 | strategy.clone = "Cli" 14 | 15 | [platforms.github] 16 | # Default configuration for GitHub.com. 17 | type = "github" 18 | 19 | [platforms.ghe-acme] 20 | # If you are using a GitHub Enterprise Server instance, 21 | # Specify here to enable `--fork` working with repositories on the GHE server. 22 | type = "github" 23 | host = "ghe.example.com" 24 | 25 | [[patterns]] 26 | # You can use additional patterns to specify where the repository is cloned from. 27 | # For details of regular expression syntax, see https://docs.rs/regex/latest/regex/index.html . 28 | regex = "^(?Phttps)://(?Pgit\\.kernel\\.org)/pub/scm/linux/kernel/git/(?P.+)/(?P.+)\\.git" 29 | 30 | # You can override parameters if those are not in or different from the pattern. 31 | vcs = "git" 32 | scheme = "https" 33 | user = "" 34 | host = "git.kernel.org" 35 | owner = "torvalds" 36 | 37 | # Composes URL different from the input. 38 | # This does not work when inferring is enabled. 39 | url = "{{scheme}}://{{host}}/pub/scm/linux/kernel/git/{{owner}}/{{repo}}.{{vcs}}" 40 | 41 | # Turn off inferring URL to use the raw input to clone. 42 | infer = false 43 | 44 | [profiles.work] 45 | # Overrides Git profile using the profile. 46 | # You need to add rule(s) to attach this profile onto a repository. 47 | user.name = "My Working Name" 48 | user.email = "my_working.email@example.com" 49 | 50 | # Adds entries to .git/info/exclude (not .gitignore). 51 | excludes = [ 52 | "/.idea/", 53 | ".DS_Store", 54 | ] 55 | 56 | [applications.vscode] 57 | # You can open a repository in VS Code using `ghr open vscode`. 58 | cmd = "code" 59 | args = ["%p"] 60 | 61 | [[rules]] 62 | # 'work' profile declared above is attached on this rule. 63 | profile.name = "work" 64 | 65 | # This rule is applied when the repository is owned by your company on GitHub. 66 | host = "github.com" 67 | owner = "my-company-org" 68 | 69 | # Optionally you can apply the rule onto a specific repo. 70 | #repo = "company-repo" 71 | -------------------------------------------------------------------------------- /src/cmd/init.rs: -------------------------------------------------------------------------------- 1 | use std::path::PathBuf; 2 | 3 | use anyhow::Result; 4 | use clap::Parser; 5 | use console::style; 6 | use dialoguer::Confirm; 7 | use git2::Repository; 8 | use tracing::info; 9 | 10 | use crate::config::Config; 11 | use crate::path::Path; 12 | use crate::root::Root; 13 | use crate::url::Url; 14 | 15 | #[derive(Debug, Parser)] 16 | pub struct Cmd { 17 | /// URL or pattern of the repository to clone. 18 | repo: String, 19 | 20 | /// Change directory after cloned a repository (Shell extension required). 21 | #[clap(long)] 22 | cd: bool, 23 | 24 | /// Opens the directory after cloned a repository. 25 | #[clap(long)] 26 | open: Option>, 27 | } 28 | 29 | impl Cmd { 30 | pub fn run(self) -> Result<()> { 31 | let root = Root::find()?; 32 | let config = Config::load_from(&root)?; 33 | 34 | let url = Url::from_str( 35 | &self.repo, 36 | &config.patterns, 37 | config.defaults.owner.as_deref(), 38 | )?; 39 | let path = Path::resolve(&root, &url); 40 | let profile = config 41 | .rules 42 | .resolve(&url) 43 | .and_then(|r| config.profiles.resolve(&r.profile)); 44 | 45 | let path = PathBuf::from(&path); 46 | if path.exists() 47 | && !Confirm::new() 48 | .with_prompt(format!( 49 | "{} The directory already exists. Are you sure want to re-initialise?", 50 | style("CHECK").dim(), 51 | )) 52 | .interact()? 53 | { 54 | return Ok(()); 55 | } 56 | 57 | let repo = Repository::init(&path)?; 58 | 59 | info!( 60 | "Initialised a repository successfully in: {}", 61 | repo.workdir().unwrap().to_string_lossy(), 62 | ); 63 | 64 | if let Some((name, p)) = profile { 65 | p.apply(&repo)?; 66 | 67 | info!("Attached profile [{}] successfully.", style(name).bold()); 68 | } 69 | 70 | if let Some(app) = self.open { 71 | config 72 | .applications 73 | .open_or_intermediate_or_default(app.as_deref(), &path)?; 74 | 75 | info!( 76 | "Opened the repository in [{}] successfully.", 77 | style(app.as_deref().unwrap_or("")).bold(), 78 | ); 79 | } 80 | 81 | Ok(()) 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/application.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | use std::ops::Deref; 3 | use std::path::Path; 4 | use std::process::Command; 5 | 6 | use anyhow::Result; 7 | use serde::Deserialize; 8 | 9 | #[derive(Debug, Deserialize)] 10 | pub struct Application { 11 | cmd: String, 12 | args: Vec, 13 | } 14 | 15 | impl Application { 16 | pub fn intermediate(cmd: &str) -> Self { 17 | Self { 18 | cmd: cmd.to_string(), 19 | args: vec!["%p".to_string()], 20 | } 21 | } 22 | 23 | pub fn open

(&self, path: P) -> Result<()> 24 | where 25 | P: AsRef, 26 | { 27 | let _ = Command::new(&self.cmd) 28 | .args( 29 | self.args 30 | .iter() 31 | .map(|arg| match arg.as_str() { 32 | "%p" => path.as_ref().to_string_lossy().to_string(), 33 | _ => arg.to_string(), 34 | }) 35 | .collect::>(), 36 | ) 37 | .spawn()?; 38 | 39 | Ok(()) 40 | } 41 | } 42 | 43 | #[cfg(windows)] 44 | impl Default for Application { 45 | fn default() -> Self { 46 | Self { 47 | cmd: "explorer.exe".to_string(), 48 | args: vec!["%p".to_string()], 49 | } 50 | } 51 | } 52 | 53 | #[cfg(not(windows))] 54 | impl Default for Application { 55 | fn default() -> Self { 56 | Self { 57 | cmd: "open".to_string(), 58 | args: vec!["%p".to_string()], 59 | } 60 | } 61 | } 62 | 63 | #[derive(Debug, Default, Deserialize)] 64 | pub struct Applications { 65 | #[serde(flatten)] 66 | map: HashMap, 67 | } 68 | 69 | impl Applications { 70 | pub fn open

(&self, name: &str, path: P) -> Option> 71 | where 72 | P: AsRef, 73 | { 74 | self.get(name).map(|a| a.open(path)) 75 | } 76 | 77 | pub fn open_or_intermediate

(&self, name: &str, path: P) -> Result<()> 78 | where 79 | P: AsRef, 80 | { 81 | self.open(name, &path) 82 | .unwrap_or_else(|| Application::intermediate(name).open(&path)) 83 | } 84 | 85 | pub fn open_or_intermediate_or_default

(&self, name: Option<&str>, path: P) -> Result<()> 86 | where 87 | P: AsRef, 88 | { 89 | match name { 90 | Some(n) => self.open_or_intermediate(n, path), 91 | _ => Application::default().open(path), 92 | } 93 | } 94 | } 95 | 96 | impl Deref for Applications { 97 | type Target = HashMap; 98 | 99 | fn deref(&self) -> &Self::Target { 100 | &self.map 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /src/path.rs: -------------------------------------------------------------------------------- 1 | use std::fmt::{Display, Formatter}; 2 | use std::path::PathBuf; 3 | 4 | use crate::root::Root; 5 | use crate::url::Url; 6 | 7 | #[derive(Debug, Eq, PartialEq, Hash)] 8 | pub struct Path<'a> { 9 | root: &'a Root, 10 | pub host: String, 11 | pub owner: String, 12 | pub repo: String, 13 | } 14 | 15 | impl<'a> Path<'a> { 16 | pub fn new( 17 | root: &'a Root, 18 | host: impl Into, 19 | owner: impl Into, 20 | repo: impl Into, 21 | ) -> Self { 22 | Self { 23 | root, 24 | host: host.into(), 25 | owner: owner.into(), 26 | repo: repo.into(), 27 | } 28 | } 29 | 30 | pub fn resolve(root: &'a Root, url: &Url) -> Self { 31 | Self { 32 | root, 33 | host: url.host.to_string(), 34 | owner: url.owner.clone(), 35 | repo: url.repo.clone(), 36 | } 37 | } 38 | 39 | pub fn to_string_with(&self, host: bool, owner: bool) -> String { 40 | match (host, owner) { 41 | (false, true) => format!("{}/{}", self.owner, self.repo), 42 | (true, false) => format!("{}:{}", self.host, self.repo), 43 | (false, false) => self.repo.to_string(), 44 | _ => format!("{}:{}/{}", self.host, self.owner, self.repo), 45 | } 46 | } 47 | } 48 | 49 | impl Display for Path<'_> { 50 | fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { 51 | write!(f, "{}:{}/{}", self.host, self.owner, self.repo) 52 | } 53 | } 54 | 55 | impl<'a> From<&Path<'a>> for PathBuf { 56 | fn from(p: &Path<'a>) -> Self { 57 | p.root.path().join(&p.host).join(&p.owner).join(&p.repo) 58 | } 59 | } 60 | 61 | impl<'a> From> for PathBuf { 62 | fn from(p: Path<'a>) -> Self { 63 | (&p).into() 64 | } 65 | } 66 | 67 | pub struct PartialPath<'a> { 68 | pub root: &'a Root, 69 | pub host: Option, 70 | pub owner: Option, 71 | pub repo: Option, 72 | } 73 | 74 | impl<'a> From<&PartialPath<'a>> for PathBuf { 75 | fn from(p: &PartialPath<'a>) -> Self { 76 | let mut path = p.root.path().to_owned(); 77 | 78 | match p.host.as_deref() { 79 | Some(h) => path = path.join(h), 80 | _ => return path, 81 | } 82 | 83 | match p.owner.as_deref() { 84 | Some(o) => path = path.join(o), 85 | _ => return path, 86 | } 87 | 88 | match p.repo.as_deref() { 89 | Some(r) => path.join(r), 90 | _ => path, 91 | } 92 | } 93 | } 94 | 95 | impl<'a> From> for PathBuf { 96 | fn from(p: PartialPath<'a>) -> Self { 97 | (&p).into() 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /src/console.rs: -------------------------------------------------------------------------------- 1 | use std::borrow::Cow; 2 | use std::future::Future; 3 | use std::sync::mpsc::{SendError, channel}; 4 | use std::time::Duration; 5 | 6 | use console::style; 7 | use indicatif::{MultiProgress, ProgressBar, ProgressStyle}; 8 | use tokio::task::{JoinError, JoinHandle}; 9 | 10 | fn create_spinner(message: impl Into>) -> ProgressBar { 11 | let spinner = ProgressStyle::with_template("{prefix} {spinner} {wide_msg}") 12 | .unwrap() 13 | .tick_chars("⠁⠂⠄⡀⢀⠠⠐⠈ "); 14 | 15 | ProgressBar::new(u64::MAX) 16 | .with_style(spinner) 17 | .with_prefix(format!(" {}", style("WAIT").dim())) 18 | .with_message(message) 19 | } 20 | 21 | async fn spin_while(p: ProgressBar, f: F) -> Result 22 | where 23 | F: FnOnce() -> Fut, 24 | Fut: Future>, 25 | E: From> + From, 26 | { 27 | let (tx, rx) = channel(); 28 | let progress = std::thread::spawn(move || { 29 | while rx.recv_timeout(Duration::from_millis(100)).is_err() { 30 | p.tick(); 31 | } 32 | 33 | p.finish_and_clear(); 34 | }); 35 | 36 | let res = f().await; 37 | 38 | tx.send(())?; 39 | progress.join().ok(); 40 | 41 | res 42 | } 43 | 44 | pub struct Spinner { 45 | inner: ProgressBar, 46 | } 47 | 48 | impl Spinner { 49 | pub fn new(message: impl Into>) -> Self { 50 | Self { 51 | inner: create_spinner(message), 52 | } 53 | } 54 | 55 | pub async fn spin_while(self, f: F) -> Result 56 | where 57 | F: FnOnce() -> Fut, 58 | Fut: Future>, 59 | E: From> + From, 60 | { 61 | spin_while(self.inner, f).await 62 | } 63 | } 64 | 65 | pub struct MultiSpinner { 66 | inner: MultiProgress, 67 | handles: Vec>>, 68 | } 69 | 70 | impl MultiSpinner 71 | where 72 | T: Send + 'static, 73 | E: Send + 'static, 74 | { 75 | pub fn new() -> Self { 76 | Self { 77 | inner: MultiProgress::new(), 78 | handles: Vec::new(), 79 | } 80 | } 81 | 82 | pub fn with_spin_while(mut self, message: impl Into>, f: F) -> Self 83 | where 84 | F: FnOnce() -> Fut + Send + 'static, 85 | Fut: Future> + Send + 'static, 86 | E: From> + From, 87 | { 88 | let p = self.inner.add(create_spinner(message)); 89 | self.handles.push(tokio::spawn(spin_while(p, f))); 90 | self 91 | } 92 | 93 | pub async fn collect(self) -> Result, E> 94 | where 95 | E: From + From, 96 | { 97 | let mut results = Vec::with_capacity(self.handles.len()); 98 | for h in self.handles { 99 | results.push(h.await??); 100 | } 101 | 102 | self.inner.clear()?; 103 | 104 | Ok(results) 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /src/git/strategy/cli.rs: -------------------------------------------------------------------------------- 1 | use std::path::Path; 2 | use std::process::Command; 3 | 4 | use anyhow::anyhow; 5 | use tracing::debug; 6 | 7 | use crate::git::{CheckoutBranch, CloneOptions, CloneRepository, Fetch}; 8 | 9 | pub struct Cli; 10 | 11 | impl CloneRepository for Cli { 12 | fn clone_repository(&self, url: U, path: P, options: &CloneOptions) -> anyhow::Result<()> 13 | where 14 | U: ToString, 15 | P: AsRef, 16 | { 17 | debug!("Cloning the repository using CLI strategy"); 18 | 19 | let mut args = vec![ 20 | "clone".to_string(), 21 | url.to_string(), 22 | path.as_ref().to_string_lossy().to_string(), 23 | ]; 24 | 25 | if let Some(recursive) = options.recursive.as_ref() { 26 | args.push(match recursive.as_deref() { 27 | Some(path) => format!("--recurse-submodules={path}"), 28 | _ => "--recurse-submodules".to_string(), 29 | }); 30 | } 31 | if options.single_branch { 32 | args.push("--single-branch".to_string()); 33 | } 34 | if let Some(origin) = options.origin.as_deref() { 35 | args.push(format!("--origin={origin}")); 36 | } 37 | if let Some(branch) = options.branch.as_deref() { 38 | args.push(format!("--branch={branch}")); 39 | } 40 | 41 | let output = Command::new("git").args(args).output()?; 42 | match output.status.success() { 43 | true => Ok(()), 44 | _ => Err(anyhow!( 45 | "Error occurred while cloning the repository: {}", 46 | String::from_utf8_lossy(output.stderr.as_slice()).trim(), 47 | )), 48 | } 49 | } 50 | } 51 | 52 | impl Fetch for Cli { 53 | fn fetch

(&self, path: P, remote: impl Into) -> anyhow::Result<()> 54 | where 55 | P: AsRef, 56 | { 57 | let output = Command::new("git") 58 | .current_dir(path) 59 | .args(["fetch".to_string(), remote.into()]) 60 | .output()?; 61 | 62 | match output.status.success() { 63 | true => Ok(()), 64 | _ => Err(anyhow!( 65 | "Error occurred while fetching the remote: {}", 66 | String::from_utf8_lossy(output.stderr.as_slice()).trim(), 67 | )), 68 | } 69 | } 70 | } 71 | 72 | impl CheckoutBranch for Cli { 73 | fn checkout_branch

( 74 | &self, 75 | path: P, 76 | branch: impl Into, 77 | track: impl Into>, 78 | ) -> anyhow::Result<()> 79 | where 80 | P: AsRef, 81 | { 82 | let mut args = Vec::from(["checkout".to_string(), "-b".to_string(), branch.into()]); 83 | if let Some(t) = track.into() { 84 | args.push("--track".to_string()); 85 | args.push(t); 86 | } 87 | 88 | let output = Command::new("git").current_dir(path).args(args).output()?; 89 | match output.status.success() { 90 | true => Ok(()), 91 | _ => Err(anyhow!( 92 | "Error occurred while fetching the remote: {}", 93 | String::from_utf8_lossy(output.stderr.as_slice()).trim(), 94 | )), 95 | } 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /resources/shell/fish/ghr-completion.fish: -------------------------------------------------------------------------------- 1 | function __fish_is_arg_n --argument-names n 2 | test $n -eq (count (string match -v -- '-*' (commandline -poc))) 3 | end 4 | 5 | function __ghr_complete_repos 6 | set query (commandline -ct) 7 | if test "$query" = '' 8 | ghr list 9 | else 10 | ghr search "$query" 11 | end 12 | end 13 | 14 | # Disable the default filename completion 15 | complete -c ghr -f 16 | 17 | # Complete commands with their description 18 | complete -c ghr -n "__fish_is_arg_n 1" -a add -d "Add an existing repository into the ghr managed directory" 19 | complete -c ghr -n "__fish_is_arg_n 1" -a browse -d "Browse a repository on web" 20 | complete -c ghr -n "__fish_is_arg_n 1" -a cd -d "Change directory to one of the managed repositories (Shell extension required)" 21 | complete -c ghr -n "__fish_is_arg_n 1" -a clone -d "Clones a Git repository to local" 22 | complete -c ghr -n "__fish_is_arg_n 1" -a delete -d "Deletes a repository from local" 23 | complete -c ghr -n "__fish_is_arg_n 1" -a init -d "Initialises a Git repository in local" 24 | complete -c ghr -n "__fish_is_arg_n 1" -a list -d "Lists all managed repositories" 25 | complete -c ghr -n "__fish_is_arg_n 1" -a open -d "Opens a repository in an application" 26 | complete -c ghr -n "__fish_is_arg_n 1" -a path -d "Prints the path to root, owner, or a repository" 27 | complete -c ghr -n "__fish_is_arg_n 1" -a profile -d "Manages profiles to use in repositories" 28 | complete -c ghr -n "__fish_is_arg_n 1" -a search -d "Perform a fuzzy search on the repositories list" 29 | complete -c ghr -n "__fish_is_arg_n 1" -a shell -d "Writes a shell script to extend ghr features" 30 | complete -c ghr -n "__fish_is_arg_n 1" -a sync -d "Sync repositories between your devices" 31 | complete -c ghr -n "__fish_is_arg_n 1" -a version -d "Prints the version of this application" 32 | 33 | # Complete the 2nd argument of add command using the file path 34 | complete -c ghr -n "__fish_is_arg_n 2; and __fish_seen_subcommand_from add" -f 35 | 36 | # Complete the 2nd argument of cd, delete, path, open, and browse commands using the repository list 37 | complete -c ghr -n "__fish_is_arg_n 2; and __fish_seen_subcommand_from browse cd delete path open" -a "(__ghr_complete_repos)" 38 | 39 | # Complete the 3rd argument of open command using the known command list 40 | complete -c ghr -n "__fish_is_arg_n 3; and __fish_seen_subcommand_from open" -a "(complete -C '')" 41 | 42 | # Complete subcommands of profile command with their description 43 | complete -c ghr -n "__fish_is_arg_n 2; and __fish_seen_subcommand_from profile" -a list -d "Lists all configured profiles" 44 | complete -c ghr -n "__fish_is_arg_n 2; and __fish_seen_subcommand_from profile" -a show -d "Shows a profile in TOML format" 45 | complete -c ghr -n "__fish_is_arg_n 2; and __fish_seen_subcommand_from profile" -a apply -d "Apply a profile" 46 | 47 | # Complete the 3rd argument of profile list subcommand using the profile list 48 | complete -c ghr -n "__fish_is_arg_n 3; and __fish_seen_subcommand_from profile; and __fish_seen_subcommand_from show apply" -a "(ghr profile list --short)" 49 | 50 | # Complete subcommands of sync command with their description 51 | complete -c ghr -n "__fish_is_arg_n 2; and __fish_seen_subcommand_from sync" -a dump -d "Dump remotes and the current ref of all repositories" 52 | complete -c ghr -n "__fish_is_arg_n 2; and __fish_seen_subcommand_from sync" -a restore -d "Restore repositories from the dumped file" 53 | -------------------------------------------------------------------------------- /src/platform/github.rs: -------------------------------------------------------------------------------- 1 | use anyhow::{Context, Result, anyhow, bail}; 2 | use async_trait::async_trait; 3 | use gh_config::{GITHUB_COM, Hosts, is_enterprise, retrieve_token_from_env, retrieve_token_secure}; 4 | use octocrab::Octocrab; 5 | use serde::Deserialize; 6 | 7 | use crate::platform::{Browse, Fork, Platform, PlatformInit}; 8 | use crate::url::Url; 9 | 10 | fn default_host() -> String { 11 | GITHUB_COM.to_string() 12 | } 13 | 14 | #[derive(Debug, Deserialize, Clone)] 15 | pub struct Config { 16 | #[serde(default = "default_host")] 17 | pub(super) host: String, 18 | } 19 | 20 | impl Default for Config { 21 | fn default() -> Self { 22 | Self { 23 | host: default_host(), 24 | } 25 | } 26 | } 27 | 28 | pub struct GitHub { 29 | client: Octocrab, 30 | config: Config, 31 | } 32 | 33 | impl PlatformInit for GitHub { 34 | type Config = Config; 35 | 36 | fn init(config: &Config) -> Result { 37 | // Try to load ~/.config/gh/hosts.yml if exists. 38 | let hosts = match Hosts::load() { 39 | Ok(h) => Some(h), 40 | Err(gh_config::Error::Io(_)) => None, 41 | Err(e) => return Err(e).context("Could not read the configuration of gh CLI."), 42 | }; 43 | 44 | let host = config.host.as_str(); 45 | let token = match hosts { 46 | // If the hosts.yml exists, retrieve token from the env, hosts.yml, or secure storage. 47 | Some(h) => h.retrieve_token(host)?, 48 | // Otherwise, retrieve token from the env or secure storage, skipping hosts.yml. 49 | _ => match retrieve_token_from_env(is_enterprise(host)) { 50 | Some(t) => Some(t), 51 | _ => retrieve_token_secure(host)?, 52 | }, 53 | }; 54 | 55 | let token = match token { 56 | Some(t) => t, 57 | _ => bail!( 58 | "GitHub access token could not be found. Install the gh CLI and login, or provide an token as GH_TOKEN environment variable." 59 | ), 60 | }; 61 | 62 | let mut builder = Octocrab::builder().personal_token(token); 63 | if config.host != GITHUB_COM { 64 | builder = builder.base_uri(format!("https://{}/api/v3", &config.host))?; 65 | } 66 | 67 | Ok(Self { 68 | client: builder.build()?, 69 | config: config.clone(), 70 | }) 71 | } 72 | } 73 | 74 | impl Platform for GitHub {} 75 | 76 | #[async_trait] 77 | impl Fork for GitHub { 78 | async fn fork(&self, url: &Url, owner: Option) -> Result { 79 | let request = self.client.repos(&url.owner, &url.repo); 80 | let request = match owner { 81 | Some(o) => request.create_fork().organization(o), 82 | _ => request.create_fork(), 83 | }; 84 | 85 | Ok(request 86 | .send() 87 | .await? 88 | .html_url 89 | .as_ref() 90 | .ok_or_else(|| anyhow!("GitHub API did not return HTML URL for the repository."))? 91 | .to_string()) 92 | } 93 | } 94 | 95 | #[async_trait] 96 | impl Browse for GitHub { 97 | async fn get_browsable_url(&self, url: &Url) -> Result { 98 | Ok(url::Url::parse(&format!( 99 | "https://{}/{}/{}", 100 | self.config.host, url.owner, url.repo 101 | ))?) 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /src/cmd/mod.rs: -------------------------------------------------------------------------------- 1 | use std::io::stderr; 2 | 3 | use anyhow::Result; 4 | use clap::{Parser, Subcommand}; 5 | use tracing_subscriber::EnvFilter; 6 | use tracing_subscriber::filter::LevelFilter; 7 | 8 | mod add; 9 | mod browse; 10 | mod cd; 11 | mod clone; 12 | mod delete; 13 | mod init; 14 | mod list; 15 | mod open; 16 | mod path; 17 | mod profile; 18 | mod search; 19 | mod shell; 20 | mod sync; 21 | mod version; 22 | 23 | #[derive(Debug, Subcommand)] 24 | pub enum Action { 25 | /// Add an existing repository into the ghr managed directory. 26 | Add(add::Cmd), 27 | /// Browse a repository on web. 28 | Browse(browse::Cmd), 29 | /// Change directory to one of the managed repositories (Shell extension required). 30 | Cd(cd::Cmd), 31 | /// Clones a Git repository to local. 32 | Clone(clone::Cmd), 33 | /// Deletes a repository from local. 34 | Delete(delete::Cmd), 35 | /// Initialises a Git repository in local. 36 | Init(init::Cmd), 37 | /// Lists all managed repositories. 38 | List(list::Cmd), 39 | /// Opens a repository in an application. 40 | Open(open::Cmd), 41 | /// Prints the path to root, owner, or a repository. 42 | Path(path::Cmd), 43 | /// Manages profiles to use in repositories. 44 | Profile(profile::Cmd), 45 | /// Perform a fuzzy search on the repositories list. 46 | Search(search::Cmd), 47 | /// Writes a shell script to extend ghr features. 48 | Shell(shell::Cmd), 49 | /// Sync repositories between your devices. 50 | Sync(sync::Cmd), 51 | /// Prints the version of this application. 52 | Version(version::Cmd), 53 | } 54 | 55 | #[derive(Debug, Parser)] 56 | pub struct Cli { 57 | #[clap(subcommand)] 58 | action: Action, 59 | 60 | /// Operates quietly. Errors will be reported even if this option is enabled. 61 | #[clap(short, long, global = true)] 62 | quiet: bool, 63 | 64 | /// Operates verbosely. Traces, debug logs will be reported. 65 | #[clap(short, long, global = true)] 66 | verbose: bool, 67 | } 68 | 69 | impl Cli { 70 | pub async fn run(self) -> Result<()> { 71 | tracing_subscriber::fmt() 72 | .compact() 73 | .without_time() 74 | .with_target(false) 75 | .with_env_filter( 76 | EnvFilter::builder() 77 | .with_default_directive(match (self.quiet, self.verbose) { 78 | (true, _) => LevelFilter::ERROR.into(), 79 | (_, true) => LevelFilter::TRACE.into(), 80 | _ => LevelFilter::INFO.into(), 81 | }) 82 | .from_env_lossy(), 83 | ) 84 | .with_writer(stderr) 85 | .init(); 86 | 87 | use Action::*; 88 | match self.action { 89 | Add(cmd) => cmd.run(), 90 | Cd(cmd) => cmd.run(), 91 | Clone(cmd) => cmd.run().await, 92 | Delete(cmd) => cmd.run().await, 93 | Init(cmd) => cmd.run(), 94 | List(cmd) => cmd.run(), 95 | Open(cmd) => cmd.run(), 96 | Browse(cmd) => cmd.run().await, 97 | Path(cmd) => cmd.run(), 98 | Profile(cmd) => cmd.run(), 99 | Search(cmd) => cmd.run(), 100 | Shell(cmd) => cmd.run(), 101 | Sync(cmd) => cmd.run().await, 102 | Version(cmd) => cmd.run(), 103 | } 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /src/cmd/sync/restore.rs: -------------------------------------------------------------------------------- 1 | use std::io::{read_to_string, stdin}; 2 | use std::path::PathBuf; 3 | 4 | use anyhow::Result; 5 | use clap::Parser; 6 | use git2::{ErrorCode, Repository as GitRepository}; 7 | use tracing::info; 8 | 9 | use crate::cmd::clone; 10 | use crate::config::Config; 11 | use crate::console::Spinner; 12 | use crate::git::{CheckoutBranch, Fetch}; 13 | use crate::path::Path; 14 | use crate::root::Root; 15 | use crate::sync::{File, Ref, Repository}; 16 | use crate::url::Url; 17 | 18 | #[derive(Debug, Parser)] 19 | pub struct Cmd {} 20 | 21 | impl Cmd { 22 | pub async fn run(self) -> Result<()> { 23 | let root = Root::find()?; 24 | let config = Config::load_from(&root)?; 25 | let file = toml::from_str::(read_to_string(stdin())?.as_str())?; 26 | 27 | for Repository { 28 | host, 29 | owner, 30 | repo, 31 | r#ref, 32 | remotes, 33 | } in file.repositories 34 | { 35 | let origin = remotes.iter().find(|r| { 36 | Url::from_str(&r.url, &config.patterns, config.defaults.owner.as_deref()) 37 | .ok() 38 | .map(|u| u.host.to_string() == host) 39 | .unwrap_or_default() 40 | }); 41 | 42 | clone::Cmd { 43 | repo: vec![ 44 | origin 45 | .map(|r| r.url.to_string()) 46 | .unwrap_or_else(|| format!("{}:{}/{}", host, owner, repo)), 47 | ], 48 | origin: origin.map(|r| r.name.to_string()), 49 | ..Default::default() 50 | } 51 | .run() 52 | .await?; 53 | 54 | let path = PathBuf::from(Path::new(&root, host, owner, repo)); 55 | let repo = GitRepository::open(&path)?; 56 | 57 | for remote in remotes { 58 | if let Err(e) = repo 59 | .remote(&remote.name, &remote.url) 60 | .and_then(|_| repo.remote_set_pushurl(&remote.name, remote.push_url.as_deref())) 61 | { 62 | match e.code() { 63 | ErrorCode::Exists => (), 64 | _ => return Err(e.into()), 65 | } 66 | } 67 | 68 | Spinner::new("Fetching objects from remotes...") 69 | .spin_while(|| async { 70 | config.git.strategy.fetch.fetch(&path, &remote.name)?; 71 | Ok::<(), anyhow::Error>(()) 72 | }) 73 | .await?; 74 | 75 | info!("Fetched from remote: {}", &remote.name); 76 | } 77 | 78 | match r#ref { 79 | Some(Ref::Remote(r)) => { 80 | repo.checkout_tree(&repo.revparse_single(&r)?, None)?; 81 | 82 | info!("Successfully checked out a remote ref: {}", &r); 83 | } 84 | Some(Ref::Branch(b)) => { 85 | config.git.strategy.checkout.checkout_branch( 86 | &path, 87 | &b.name, 88 | Some(b.upstream.to_string()), 89 | )?; 90 | 91 | info!("Successfully checked out a branch: {}", &b.name); 92 | } 93 | _ => (), 94 | } 95 | } 96 | 97 | Ok(()) 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /resources/shell/bash/ghr-completion.bash: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | __ghr_complete__static() { 4 | local options 5 | 6 | options=("${@:2}") 7 | 8 | compgen -W "${options[*]}" -- "$1" 9 | } 10 | 11 | __ghr_complete__repos() { 12 | if [[ $1 != -* && ${COMP_CWORD} -ne 2 ]]; then 13 | return 14 | fi 15 | 16 | if [ "$1" = '' ]; then 17 | ghr list 18 | else 19 | ghr search "$1" 20 | fi 21 | } 22 | 23 | __ghr_complete__profiles() { 24 | local profiles suggestions 25 | 26 | profiles="$(ghr profile list --short)" 27 | suggestions="$(compgen -W "${profiles[*]}" -- "$1")" 28 | 29 | if [[ $1 != -* && ${COMP_CWORD} -ne 3 ]]; then 30 | return 31 | fi 32 | 33 | echo "$suggestions" 34 | } 35 | 36 | __ghr_complete() { 37 | local cword 38 | 39 | # Replaces ':' in $COMP_WORDBREAKS to prevent bash appends the suggestion after ':' repeatedly 40 | COMP_WORDBREAKS=${COMP_WORDBREAKS//:/} 41 | 42 | cword="${COMP_WORDS[COMP_CWORD]}" 43 | 44 | if [ "${COMP_CWORD}" = 1 ]; then 45 | COMPREPLY=($(__ghr_complete__static "${cword}" --help add browse cd clone delete help init list open path profile shell sync version)) 46 | return 0 47 | fi 48 | 49 | case "${COMP_WORDS[1]}" in 50 | add) 51 | COMPREPLY=($(__ghr_complete__static "${cword}" --help)) 52 | ;; 53 | browse) 54 | COMPREPLY=($(__ghr_complete__repos "${cword}")) 55 | ;; 56 | cd) 57 | COMPREPLY=($(__ghr_complete__repos "${cword}")) 58 | ;; 59 | clone) 60 | COMPREPLY=($(__ghr_complete__static "${cword}" --help)) 61 | ;; 62 | delete) 63 | COMPREPLY=($(__ghr_complete__repos "${cword}")) 64 | ;; 65 | init) 66 | COMPREPLY=($(__ghr_complete__static "${cword}" --help)) 67 | ;; 68 | list) 69 | COMPREPLY=($(__ghr_complete__static "${cword}" --help --no-host --no-owner -p --path)) 70 | ;; 71 | open) 72 | if [ "${COMP_CWORD}" = 2 ]; then 73 | COMPREPLY=($(__ghr_complete__repos "${cword}" --help)) 74 | elif [ "${COMP_CWORD}" = 3 ]; then 75 | # Complete a known command to open the repository using 76 | COMPREPLY=($(compgen -c -- "${cword}")) 77 | fi 78 | ;; 79 | path) 80 | COMPREPLY=($(__ghr_complete__repos "${cword}")) 81 | ;; 82 | profile) 83 | if [ "$COMP_CWORD" = 2 ]; then 84 | COMPREPLY=($(__ghr_complete__static "${cword}" --help list show apply)) 85 | else 86 | case "${COMP_WORDS[2]}" in 87 | show|apply) 88 | COMPREPLY=($(__ghr_complete__profiles "${cword}" --help)) 89 | ;; 90 | *) 91 | ;; 92 | esac 93 | fi 94 | ;; 95 | search) 96 | COMPREPLY=($(__ghr_complete__static "${cword}" --help)) 97 | ;; 98 | shell) 99 | COMPREPLY=($(__ghr_complete__static "${cword}" --help)) 100 | ;; 101 | sync) 102 | COMPREPLY=($(__ghr_complete__static "${cword}" --help dump restore)) 103 | ;; 104 | version) 105 | COMPREPLY=($(__ghr_complete__static "${cword}" --help)) 106 | ;; 107 | help) 108 | COMPREPLY=() 109 | ;; 110 | *) 111 | ;; 112 | esac 113 | } 114 | 115 | # complete is a bash builtin, but recent versions of ZSH come with a function 116 | # called bashcompinit that will create a complete in ZSH. If the user is in 117 | # ZSH, load and run bashcompinit before calling the complete function. 118 | if [[ -n ${ZSH_VERSION-} ]]; then 119 | # First calling compinit (only if not called yet!) 120 | # and then bashcompinit as mentioned by zsh man page. 121 | if ! command -v compinit >/dev/null; then 122 | autoload -U +X compinit && if [[ ${ZSH_DISABLE_COMPFIX-} = true ]]; then 123 | compinit -u 124 | else 125 | compinit 126 | fi 127 | fi 128 | autoload -U +X bashcompinit && bashcompinit 129 | fi 130 | 131 | complete -F __ghr_complete -o bashdefault -o default ghr 132 | -------------------------------------------------------------------------------- /src/cmd/add.rs: -------------------------------------------------------------------------------- 1 | use std::fs::{create_dir_all, rename}; 2 | use std::path::PathBuf; 3 | 4 | use anyhow::{Result, anyhow, bail}; 5 | use clap::Parser; 6 | use console::style; 7 | use dialoguer::Confirm; 8 | use git2::{Remote, Repository}; 9 | use itertools::Itertools; 10 | use tracing::info; 11 | 12 | use crate::config::Config; 13 | use crate::path::Path; 14 | use crate::root::Root; 15 | use crate::url::Url; 16 | 17 | #[derive(Debug, Parser)] 18 | pub struct Cmd { 19 | /// Path to the repository to add. 20 | repo: String, 21 | 22 | /// Forces to add the repository without any prompt. 23 | #[clap(short, long)] 24 | force: bool, 25 | 26 | /// Change directory after added the repository. (Shell extension required) 27 | #[clap(long)] 28 | cd: bool, 29 | 30 | /// Opens the directory after added the repository. 31 | #[clap(long)] 32 | open: Option>, 33 | } 34 | 35 | impl Cmd { 36 | pub fn run(self) -> Result<()> { 37 | let root = Root::find()?; 38 | let config = Config::load_from(&root)?; 39 | 40 | let repo = Repository::open(&self.repo)?; 41 | let remotes: Vec = repo 42 | .remotes()? 43 | .iter() 44 | .flatten() 45 | .map(|r| repo.find_remote(r)) 46 | .try_collect()?; 47 | 48 | let url = 49 | match remotes.iter().filter_map(|r| r.url()).find_map(|u| { 50 | Url::from_str(u, &config.patterns, config.defaults.owner.as_deref()).ok() 51 | }) { 52 | Some(u) => u, 53 | _ => bail!("Could not find a supported remote in the repository."), 54 | }; 55 | 56 | let _ = repo; // Closing the repository 57 | 58 | let path = Path::resolve(&root, &url); 59 | let profile = config 60 | .rules 61 | .resolve(&url) 62 | .and_then(|r| config.profiles.resolve(&r.profile)); 63 | 64 | let path = PathBuf::from(&path); 65 | 66 | info!("URL of the repository is: {}", url.to_string()); 67 | info!( 68 | "This will move entire the repository to: {}", 69 | path.to_string_lossy(), 70 | ); 71 | 72 | if !self.force 73 | && !Confirm::new() 74 | .with_prompt(format!( 75 | "{} Are you sure want to continue?", 76 | style("CHECK").dim(), 77 | )) 78 | .interact()? 79 | { 80 | return Ok(()); 81 | } 82 | 83 | let parent_path = path 84 | .parent() 85 | .ok_or_else(|| { 86 | anyhow!( 87 | "Failed to determine parent path for the repository's new location: {}", 88 | path.to_string_lossy() 89 | ) 90 | })? 91 | .to_path_buf(); 92 | 93 | create_dir_all(parent_path)?; 94 | 95 | rename(&self.repo, &path)?; 96 | info!( 97 | "Added the repository successfully to: {}", 98 | path.to_string_lossy(), 99 | ); 100 | 101 | let repo = Repository::open(&path)?; 102 | if let Some((name, p)) = profile { 103 | p.apply(&repo)?; 104 | 105 | info!("Attached profile [{}] successfully.", style(name).bold()); 106 | } 107 | 108 | if let Some(app) = self.open { 109 | config 110 | .applications 111 | .open_or_intermediate_or_default(app.as_deref(), &path)?; 112 | 113 | info!( 114 | "Opened the repository in [{}] successfully.", 115 | style(app.as_deref().unwrap_or("")).bold(), 116 | ); 117 | } 118 | 119 | Ok(()) 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /src/cmd/browse.rs: -------------------------------------------------------------------------------- 1 | use anyhow::{Result, anyhow, bail}; 2 | use clap::Parser; 3 | use git2::Repository; 4 | 5 | use crate::config::Config; 6 | use crate::root::Root; 7 | use crate::url::Url; 8 | 9 | #[cfg(windows)] 10 | fn open_url(url: &url::Url) -> Result<()> { 11 | use std::ffi::CString; 12 | 13 | use windows::Win32::UI::Shell::ShellExecuteA; 14 | use windows::Win32::UI::WindowsAndMessaging::SHOW_WINDOW_CMD; 15 | use windows::core::{PCSTR, s}; 16 | 17 | // https://github.com/pkg/browser/issues/16 18 | // https://github.com/cli/browser/commit/28dca726a60e5e7cdf0326436aa1cb4d476c3305 19 | // https://web.archive.org/web/20150421233040/https://support.microsoft.com/en-us/kb/224816 20 | unsafe { 21 | ShellExecuteA( 22 | None, 23 | s!("open"), 24 | PCSTR::from_raw(CString::new(url.to_string().as_str())?.as_ptr() as *const u8), 25 | PCSTR::null(), 26 | PCSTR::null(), 27 | SHOW_WINDOW_CMD(0), 28 | ); 29 | } 30 | 31 | Ok(()) 32 | } 33 | 34 | #[cfg(target_os = "macos")] 35 | fn open_url(url: &url::Url) -> Result<()> { 36 | std::process::Command::new("open") 37 | .arg(url.to_string()) 38 | .spawn()? 39 | .wait()?; 40 | 41 | Ok(()) 42 | } 43 | 44 | #[cfg(all(not(windows), not(target_os = "macos")))] 45 | fn open_url(url: &url::Url) -> Result<()> { 46 | // c.f. https://github.com/cli/browser/blob/main/browser_linux.go 47 | let commands = ["xdg-open", "x-www-browser", "www-browser", "wslview"]; 48 | 49 | for command in commands { 50 | match std::process::Command::new(command) 51 | .arg(url.to_string()) 52 | .spawn() 53 | { 54 | Ok(mut child) => { 55 | child.wait()?; 56 | return Ok(()); 57 | } 58 | Err(e) => match e.kind() { 59 | std::io::ErrorKind::NotFound => continue, 60 | _ => return Err(e.into()), 61 | }, 62 | } 63 | } 64 | 65 | let commands = commands.join(", "); 66 | Err(anyhow!( 67 | "Command not found: you need one of the following commands to open a url: {commands}" 68 | )) 69 | } 70 | 71 | #[derive(Debug, Parser)] 72 | pub struct Cmd { 73 | /// URL or pattern of the repository to be browsed. 74 | /// Defaults to the default remote of the repository at the current directory. 75 | repo: Option, 76 | } 77 | 78 | impl Cmd { 79 | pub async fn run(self) -> Result<()> { 80 | let root = Root::find()?; 81 | let config = Config::load_from(&root)?; 82 | 83 | let url = match self.repo.as_deref() { 84 | Some(path) => path.to_owned(), 85 | _ => { 86 | let repo = Repository::open_from_env()?; 87 | 88 | let remotes = repo.remotes()?; 89 | let remote = match remotes.iter().flatten().next() { 90 | Some(r) => r.to_owned(), 91 | _ => bail!("The repository has no remote."), 92 | }; 93 | 94 | let remote = repo.find_remote(&remote)?; 95 | match remote.url() { 96 | Some(url) => url.to_string(), 97 | _ => bail!("Could not find the remote URL from the repository."), 98 | } 99 | } 100 | }; 101 | 102 | let url = Url::from_str(&url, &config.patterns, config.defaults.owner.as_deref())?; 103 | 104 | let platform = config 105 | .platforms 106 | .find(&url) 107 | .ok_or_else(|| anyhow!("Could not find a platform to browse on."))? 108 | .try_into_platform()?; 109 | 110 | let url = platform.get_browsable_url(&url).await?; 111 | 112 | open_url(&url)?; 113 | Ok(()) 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /.github/workflows/rust.yml: -------------------------------------------------------------------------------- 1 | name: Rust 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | release: 11 | types: 12 | - published 13 | 14 | env: 15 | CARGO_TERM_COLOR: always 16 | 17 | jobs: 18 | checks: 19 | runs-on: ubuntu-24.04 20 | steps: 21 | - uses: actions/checkout@v6 22 | 23 | - uses: Swatinem/rust-cache@v2 24 | 25 | - name: Install latest stable 26 | uses: dtolnay/rust-toolchain@stable 27 | with: 28 | targets: ${{ matrix.target }} 29 | components: rustfmt,clippy 30 | 31 | - name: Run rustfmt 32 | run: cargo fmt --all --check 33 | 34 | - name: Run clippy 35 | uses: giraffate/clippy-action@v1 36 | with: 37 | reporter: 'github-pr-check' 38 | github_token: ${{ secrets.GITHUB_TOKEN }} 39 | 40 | - name: Run tests 41 | run: cargo test --all-features 42 | 43 | build: 44 | strategy: 45 | matrix: 46 | target: 47 | - x86_64-pc-windows-msvc 48 | - x86_64-apple-darwin 49 | - aarch64-apple-darwin 50 | - x86_64-unknown-linux-gnu 51 | - aarch64-unknown-linux-gnu 52 | include: 53 | - cargo: cargo 54 | artifact: ghr 55 | - target: x86_64-pc-windows-msvc 56 | host: windows-2022 57 | artifact: ghr.exe 58 | - target: x86_64-apple-darwin 59 | host: macos-15 60 | - target: aarch64-apple-darwin 61 | host: macos-15 62 | - target: x86_64-unknown-linux-gnu 63 | host: ubuntu-24.04 64 | - target: aarch64-unknown-linux-gnu 65 | host: ubuntu-24.04-arm 66 | runs-on: ${{ matrix.host }} 67 | steps: 68 | - uses: actions/checkout@v6 69 | with: 70 | submodules: true 71 | 72 | - name: Install latest stable 73 | uses: dtolnay/rust-toolchain@stable 74 | with: 75 | targets: ${{ matrix.target }} 76 | 77 | - id: cache-key 78 | run: echo "key=$(echo '${{ toJSON(matrix) }}' | shasum -a 256)" >> $GITHUB_OUTPUT 79 | 80 | - uses: Swatinem/rust-cache@v2 81 | with: 82 | key: ${{ steps.cache-key.outputs.key }} 83 | 84 | - name: Build 85 | run: ${{ matrix.cargo }} build --release --features vendored --target '${{ matrix.target }}' 86 | 87 | - name: Compress artifacts into .tar.gz file 88 | run: tar -C ./target/${{ matrix.target }}/release -czf ghr-${{ matrix.target }}.tar.gz ${{ matrix.artifact }} 89 | 90 | - uses: actions/upload-artifact@v6 91 | with: 92 | name: ghr-${{ matrix.target }} 93 | path: ghr-${{ matrix.target }}.tar.gz 94 | 95 | - uses: svenstaro/upload-release-action@v2 96 | if: ${{ github.event_name == 'release' }} 97 | with: 98 | file: ghr-${{ matrix.target }}.tar.gz 99 | overwrite: true 100 | 101 | deploy: 102 | runs-on: ubuntu-24.04 103 | if: ${{ github.event_name == 'release' }} 104 | needs: 105 | - checks 106 | steps: 107 | - uses: actions/checkout@v6 108 | 109 | - uses: Swatinem/rust-cache@v2 110 | 111 | - name: Install latest stable 112 | uses: dtolnay/rust-toolchain@stable 113 | 114 | - name: Log into crates.io 115 | run: cargo login '${{ secrets.CRATES_IO_TOKEN }}' 116 | 117 | - name: Publish to crates.io 118 | run: cargo publish --allow-dirty 119 | 120 | brew: 121 | runs-on: macos-15 122 | if: ${{ github.event_name == 'release' }} 123 | needs: 124 | - build 125 | steps: 126 | - name: Set up Homebrew 127 | uses: Homebrew/actions/setup-homebrew@master 128 | 129 | - name: Set up git 130 | uses: Homebrew/actions/git-user-config@master 131 | 132 | - name: Tap s6n-jp/tap 133 | run: brew tap s6n-jp/tap 134 | 135 | - name: Create a bump PR 136 | uses: Homebrew/actions/bump-packages@master 137 | with: 138 | token: ${{ secrets.HOMEBREW_TAP_TOKEN }} 139 | formulae: s6n-jp/tap/ghr 140 | 141 | scoop: 142 | runs-on: ubuntu-24.04 143 | if: ${{ github.event_name == 'release' }} 144 | needs: 145 | - build 146 | steps: 147 | - uses: actions/checkout@v6 148 | 149 | - name: Release to Scoop Bucket 150 | uses: siketyan/release-to-registry-action@7e2a91ef78e61dccdb2e8b9401ae54ec58fab4fc 151 | with: 152 | path: 'ghr.json' 153 | assets: |- 154 | windows_x86_64=ghr-x86_64-pc-windows-msvc.tar.gz 155 | message: 'feat(ghr): Release ${{ github.event.release.tag_name }}' 156 | template: './resources/scoop/ghr.json.mustache' 157 | token: '${{ secrets.SCOOP_BUCKET_TOKEN }}' 158 | hash: 'sha256' 159 | targetRepo: 'scoop-bucket' 160 | branch: 'ghr/${{ github.event.release.tag_name }}' 161 | author: 'github-actions[bot] ' 162 | committer: 'github-actions[bot] ' 163 | -------------------------------------------------------------------------------- /src/sync.rs: -------------------------------------------------------------------------------- 1 | use std::path::PathBuf; 2 | 3 | use anyhow::{Result, bail}; 4 | use git2::{BranchType, ErrorCode, Reference, Repository as GitRepository}; 5 | use serde::{Deserialize, Serialize}; 6 | use tracing::warn; 7 | 8 | use crate::path::Path; 9 | 10 | #[derive(Deserialize, Serialize)] 11 | pub struct BranchRef { 12 | pub name: String, 13 | pub upstream: String, 14 | } 15 | 16 | #[derive(Deserialize, Serialize)] 17 | #[serde(tag = "type")] 18 | pub enum Ref { 19 | #[serde(rename = "remote")] 20 | Remote(String), 21 | #[serde(rename = "branch")] 22 | Branch(BranchRef), 23 | // tag is not supported ... yet. 24 | } 25 | 26 | #[derive(Deserialize, Serialize)] 27 | pub struct Remote { 28 | pub name: String, 29 | pub url: String, 30 | pub push_url: Option, 31 | } 32 | 33 | #[derive(Deserialize, Serialize)] 34 | pub struct Repository { 35 | pub host: String, 36 | pub owner: String, 37 | pub repo: String, 38 | pub r#ref: Option, 39 | #[serde(default)] 40 | pub remotes: Vec, 41 | } 42 | 43 | impl Repository { 44 | pub fn save(path: &Path) -> Result { 45 | let repo = match GitRepository::open(PathBuf::from(path)) { 46 | Ok(r) => r, 47 | Err(e) => match e.code() { 48 | ErrorCode::NotFound => bail!("Not a Git repository"), 49 | _ => return Err(e.into()), 50 | }, 51 | }; 52 | 53 | let head = match repo.head() { 54 | Ok(r) => r, 55 | Err(e) => match e.code() { 56 | ErrorCode::UnbornBranch => bail!("HEAD is an unborn branch"), 57 | ErrorCode::NotFound => bail!("Cannot find the HEAD"), 58 | _ => return Err(e.into()), 59 | }, 60 | }; 61 | 62 | let remotes = repo.remotes()?; 63 | if remotes.is_empty() { 64 | bail!("No remotes defined"); 65 | } 66 | 67 | let r#ref = match Self::synced_ref(&repo, &head) { 68 | Ok(r) => Some(r), 69 | Err(e) => { 70 | warn!("Repository {} is not synced to remote: {}", path, e); 71 | None 72 | } 73 | }; 74 | 75 | Ok(Self { 76 | host: path.host.to_string(), 77 | owner: path.owner.to_string(), 78 | repo: path.repo.to_string(), 79 | r#ref, 80 | remotes: remotes 81 | .iter() 82 | .flatten() 83 | .map(|name| { 84 | let remote = repo.find_remote(name)?; 85 | 86 | Ok(Remote { 87 | name: name.to_string(), 88 | url: remote.url().unwrap_or_default().to_string(), 89 | push_url: remote.pushurl().map(|u| u.to_string()), 90 | }) 91 | }) 92 | .collect::>>()?, 93 | }) 94 | } 95 | 96 | fn synced_ref(repo: &GitRepository, head: &Reference) -> Result { 97 | if head.is_remote() { 98 | return Ok(Ref::Remote(head.name().unwrap().to_string())); 99 | } 100 | 101 | if head.is_branch() { 102 | let name = head.shorthand().unwrap(); 103 | let upstream = match repo.find_branch(name, BranchType::Local)?.upstream() { 104 | Ok(b) => b, 105 | Err(e) => match e.code() { 106 | ErrorCode::NotFound => bail!("Branch has never pushed to remote"), 107 | _ => return Err(e.into()), 108 | }, 109 | }; 110 | 111 | let upstream_name = upstream.name()?.unwrap().to_string(); 112 | let reference = upstream.into_reference(); 113 | if head != &reference { 114 | bail!("Branch is not synced"); 115 | } 116 | 117 | Ok(Ref::Branch(BranchRef { 118 | name: name.to_string(), 119 | upstream: upstream_name, 120 | })) 121 | } else if head.is_tag() { 122 | bail!("HEAD is a tag"); 123 | } else { 124 | bail!("Detached HEAD"); 125 | } 126 | } 127 | } 128 | 129 | #[derive(Deserialize, Serialize)] 130 | pub enum Version { 131 | #[serde(rename = "1")] 132 | V1, 133 | } 134 | 135 | #[derive(Deserialize, Serialize)] 136 | pub struct File { 137 | pub version: Version, 138 | 139 | #[serde(default)] 140 | pub repositories: Vec, 141 | } 142 | 143 | impl<'a> FromIterator> for File { 144 | fn from_iter(iter: T) -> Self 145 | where 146 | T: IntoIterator>, 147 | { 148 | Self { 149 | version: Version::V1, 150 | repositories: iter 151 | .into_iter() 152 | .flat_map(|path| match Repository::save(&path) { 153 | Ok(r) => Some(r), 154 | Err(e) => { 155 | warn!("Skipped repository {}: {}", &path, e); 156 | None 157 | } 158 | }) 159 | .collect::>(), 160 | } 161 | } 162 | } 163 | -------------------------------------------------------------------------------- /src/git/exclude.rs: -------------------------------------------------------------------------------- 1 | use std::convert::Infallible; 2 | use std::fmt::{Display, Result as FmtResult}; 3 | use std::fs::File as StdFile; 4 | use std::io::{BufRead, BufReader, BufWriter, Read, Write}; 5 | use std::ops::{Deref, DerefMut}; 6 | use std::path::{Path, PathBuf}; 7 | use std::result::Result as StdResult; 8 | use std::str::FromStr; 9 | 10 | use anyhow::Result; 11 | 12 | #[derive(Debug, Eq, PartialEq)] 13 | pub enum Node { 14 | Empty, 15 | Comment(String), 16 | Include(String), 17 | Exclude(String), 18 | } 19 | 20 | impl FromStr for Node { 21 | type Err = Infallible; 22 | 23 | fn from_str(s: &str) -> StdResult { 24 | let mut chars = s.chars(); 25 | 26 | Ok(match chars.next() { 27 | Some('#') => Node::Comment(chars.collect()), 28 | Some('!') => Node::Include(chars.collect()), 29 | Some(_) => Node::Exclude(s.to_string()), 30 | None => Node::Empty, 31 | }) 32 | } 33 | } 34 | 35 | impl Display for Node { 36 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> FmtResult { 37 | let str = match self { 38 | Node::Empty => String::new(), 39 | Node::Comment(c) => format!("#{}", c), 40 | Node::Include(i) => format!("!{}", i), 41 | Node::Exclude(e) => e.to_string(), 42 | }; 43 | 44 | write!(f, "{}", str) 45 | } 46 | } 47 | 48 | #[derive(Debug, Default)] 49 | pub struct File { 50 | nodes: Vec, 51 | } 52 | 53 | impl File { 54 | pub fn read(reader: R) -> Result 55 | where 56 | R: Read, 57 | { 58 | let reader = BufReader::new(reader); 59 | 60 | Ok(Self { 61 | nodes: reader 62 | .lines() 63 | .map(|l| l.map(|l| Node::from_str(&l).unwrap())) 64 | .collect::, _>>()?, 65 | }) 66 | } 67 | 68 | pub fn load

(path: P) -> Result 69 | where 70 | P: AsRef, 71 | { 72 | Self::read(StdFile::open(Self::file_path(path))?) 73 | } 74 | 75 | pub fn write(&self, writer: W) -> Result<()> 76 | where 77 | W: Write, 78 | { 79 | let mut writer = BufWriter::new(writer); 80 | for node in &self.nodes { 81 | writeln!(writer, "{}", node)?; 82 | } 83 | 84 | Ok(()) 85 | } 86 | 87 | pub fn save

(&self, path: P) -> Result<()> 88 | where 89 | P: AsRef, 90 | { 91 | self.write(StdFile::create(Self::file_path(path))?) 92 | } 93 | 94 | pub fn add_or_noop(&mut self, node: Node) { 95 | if !self.nodes.contains(&node) { 96 | self.nodes.push(node); 97 | } 98 | } 99 | 100 | fn file_path

(path: P) -> PathBuf 101 | where 102 | P: AsRef, 103 | { 104 | path.as_ref().join(".git").join("info").join("exclude") 105 | } 106 | } 107 | 108 | impl Deref for File { 109 | type Target = Vec; 110 | 111 | fn deref(&self) -> &Self::Target { 112 | &self.nodes 113 | } 114 | } 115 | 116 | impl DerefMut for File { 117 | fn deref_mut(&mut self) -> &mut Self::Target { 118 | &mut self.nodes 119 | } 120 | } 121 | 122 | #[cfg(test)] 123 | mod tests { 124 | use super::*; 125 | use std::str::FromStr; 126 | 127 | #[test] 128 | fn test_node_from_str() { 129 | assert_eq!( 130 | Node::Comment("This is a comment".to_string()), 131 | Node::from_str("#This is a comment").unwrap(), 132 | ); 133 | assert_eq!( 134 | Node::Include("/path/to/**/file.ext".to_string()), 135 | Node::from_str("!/path/to/**/file.ext").unwrap(), 136 | ); 137 | assert_eq!( 138 | Node::Exclude("/path/to/**/file.ext".to_string()), 139 | Node::from_str("/path/to/**/file.ext").unwrap(), 140 | ); 141 | } 142 | 143 | #[test] 144 | fn test_node_to_string() { 145 | assert_eq!( 146 | "#This is a comment".to_string(), 147 | Node::Comment("This is a comment".to_string()).to_string(), 148 | ); 149 | assert_eq!( 150 | "!/path/to/**/file.ext".to_string(), 151 | Node::Include("/path/to/**/file.ext".to_string()).to_string(), 152 | ); 153 | assert_eq!( 154 | "/path/to/**/file.ext".to_string(), 155 | Node::Exclude("/path/to/**/file.ext".to_string()).to_string(), 156 | ); 157 | } 158 | 159 | #[test] 160 | fn test_file_read() { 161 | let content = br#" 162 | # File patterns to ignore; see `git help ignore` for more information. 163 | # Lines that start with '#' are comments. 164 | .idea 165 | "#; 166 | 167 | assert_eq!( 168 | vec![ 169 | Node::Empty, 170 | Node::Comment( 171 | " File patterns to ignore; see `git help ignore` for more information." 172 | .to_string() 173 | ), 174 | Node::Comment(" Lines that start with '#' are comments.".to_string()), 175 | Node::Exclude(".idea".to_string()), 176 | ], 177 | File::read(&content[..]).unwrap().nodes, 178 | ); 179 | } 180 | 181 | #[test] 182 | fn test_file_write() { 183 | let mut content = Vec::::new(); 184 | let mut file = File::default(); 185 | 186 | file.push(Node::Comment("This is a comment".to_string())); 187 | file.push(Node::Empty); 188 | file.push(Node::Include("/path/to/include".to_string())); 189 | file.push(Node::Exclude("/path/to/exclude".to_string())); 190 | file.write(&mut content).unwrap(); 191 | 192 | assert_eq!( 193 | r#"#This is a comment 194 | 195 | !/path/to/include 196 | /path/to/exclude 197 | "#, 198 | String::from_utf8(content).unwrap(), 199 | ) 200 | } 201 | } 202 | -------------------------------------------------------------------------------- /src/profile.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | use std::fmt::Formatter; 3 | use std::ops::Deref; 4 | use std::result::Result as StdResult; 5 | 6 | use anyhow::Result; 7 | use git2::Repository; 8 | use itertools::Itertools; 9 | use serde::de::{MapAccess, Visitor}; 10 | use serde::ser::SerializeMap; 11 | use serde::{Deserialize, Deserializer, Serialize, Serializer}; 12 | use toml::Table; 13 | use toml::value::Value; 14 | 15 | use crate::git::exclude::{File, Node}; 16 | use crate::rule::ProfileRef; 17 | 18 | #[derive(Debug, Default)] 19 | pub struct Configs { 20 | map: HashMap, 21 | } 22 | 23 | impl Configs { 24 | fn to_toml(&self) -> Table { 25 | let mut map = Table::new(); 26 | for (key, value) in &self.map { 27 | let segments: Vec<_> = key.split('.').collect(); 28 | let mut inner = &mut map; 29 | 30 | for segment in segments.iter().take(segments.len() - 1) { 31 | if !inner.contains_key(*segment) { 32 | inner.insert(segment.to_string(), Value::Table(Table::new())); 33 | } 34 | 35 | inner = match inner.get_mut(*segment).unwrap() { 36 | Value::Table(table) => table, 37 | _ => panic!("unexpected non-table value"), 38 | } 39 | } 40 | 41 | inner.insert( 42 | segments.last().unwrap().to_string(), 43 | Value::String(value.to_string()), 44 | ); 45 | } 46 | 47 | map 48 | } 49 | 50 | fn extend_from_toml(&mut self, input: &Value, current_key: &str) { 51 | match input { 52 | Value::String(value) => { 53 | self.map.insert(current_key.to_string(), value.clone()); 54 | } 55 | Value::Table(table) => { 56 | for (key, value) in table { 57 | self.extend_from_toml( 58 | value, 59 | &match key.is_empty() { 60 | true => key.clone(), 61 | _ => format!("{}.{}", current_key, key), 62 | }, 63 | ); 64 | } 65 | } 66 | _ => (), 67 | } 68 | } 69 | } 70 | 71 | impl Deref for Configs { 72 | type Target = HashMap; 73 | 74 | fn deref(&self) -> &Self::Target { 75 | &self.map 76 | } 77 | } 78 | 79 | impl<'de> Deserialize<'de> for Configs { 80 | fn deserialize(deserializer: D) -> StdResult 81 | where 82 | D: Deserializer<'de>, 83 | { 84 | deserializer.deserialize_map(ConfigsVisitor) 85 | } 86 | } 87 | 88 | impl Serialize for Configs { 89 | fn serialize(&self, serializer: S) -> std::result::Result 90 | where 91 | S: Serializer, 92 | { 93 | let mut map = serializer.serialize_map(None)?; 94 | 95 | self.to_toml() 96 | .iter() 97 | .map(|(k, v)| map.serialize_entry(k, v)) 98 | .try_collect::<_, (), _>()?; 99 | 100 | map.end() 101 | } 102 | } 103 | 104 | struct ConfigsVisitor; 105 | 106 | impl<'de> Visitor<'de> for ConfigsVisitor { 107 | type Value = Configs; 108 | 109 | fn expecting(&self, formatter: &mut Formatter) -> std::fmt::Result { 110 | formatter.write_str("git configs of a profile") 111 | } 112 | 113 | fn visit_map(self, mut map: A) -> std::result::Result 114 | where 115 | A: MapAccess<'de>, 116 | { 117 | let mut configs = Configs::default(); 118 | 119 | while let Some((key, value)) = map.next_entry::()? { 120 | configs.extend_from_toml(&value, &key) 121 | } 122 | 123 | Ok(configs) 124 | } 125 | } 126 | 127 | #[derive(Debug, Default, Deserialize, Serialize)] 128 | pub struct Profile { 129 | #[serde(default)] 130 | pub excludes: Vec, 131 | #[serde(default, flatten)] 132 | pub configs: Configs, 133 | } 134 | 135 | impl Profile { 136 | pub fn apply(&self, repo: &Repository) -> Result<()> { 137 | let path = repo.workdir().unwrap(); 138 | let mut exclude = File::load(path)?; 139 | for value in &self.excludes { 140 | exclude.add_or_noop(Node::Exclude(value.to_string())); 141 | } 142 | 143 | exclude.save(path)?; 144 | 145 | let mut config = repo.config()?; 146 | for (key, value) in &self.configs.map { 147 | config.set_str(key, value)?; 148 | } 149 | 150 | Ok(()) 151 | } 152 | } 153 | 154 | #[derive(Debug, Default, Deserialize)] 155 | pub struct Profiles { 156 | #[serde(flatten)] 157 | map: HashMap, 158 | } 159 | 160 | impl Profiles { 161 | pub fn resolve(&self, r: &ProfileRef) -> Option<(&str, &Profile)> { 162 | self.get_key_value(&r.name).map(|(s, p)| (s.as_str(), p)) 163 | } 164 | } 165 | 166 | impl Deref for Profiles { 167 | type Target = HashMap; 168 | 169 | fn deref(&self) -> &Self::Target { 170 | &self.map 171 | } 172 | } 173 | 174 | #[cfg(test)] 175 | mod tests { 176 | use super::*; 177 | 178 | #[test] 179 | fn load_git_configs() { 180 | let toml = r#" 181 | user.name = "User Taro" 182 | user.email = "taro@example.com" 183 | user.signingkey = "ABCDEFGHIJKLMNOP" 184 | "#; 185 | 186 | let profile = toml::from_str::(toml).unwrap(); 187 | let configs = &profile.configs; 188 | 189 | assert_eq!("User Taro", configs.get("user.name").unwrap().as_str()); 190 | assert_eq!( 191 | "taro@example.com", 192 | configs.get("user.email").unwrap().as_str(), 193 | ); 194 | assert_eq!( 195 | "ABCDEFGHIJKLMNOP", 196 | configs.get("user.signingkey").unwrap().as_str(), 197 | ); 198 | } 199 | } 200 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 🚀 ghr 2 | 3 | [![crates.io](https://img.shields.io/crates/v/ghr.svg)](https://crates.io/crates/ghr) 4 | [![Rust](https://github.com/siketyan/ghr/actions/workflows/rust.yml/badge.svg)](https://github.com/siketyan/ghr/actions/workflows/rust.yml) 5 | 6 | Yet another repository management with auto-attaching profiles. 7 | 8 | ## 🔥 Motivation 9 | 10 | [ghq](https://github.com/x-motemen/ghq) is the most famous solution to resolve stress of our repository management 11 | currently. 12 | However, I wanted to customise the git configuration by some rules, such as using my company email in their 13 | repositories. 14 | 15 | To achieve that, ghq was not enough for me. 16 | So I have rewritten them as simple, in Rust, the robust and modern language today. 17 | 18 | ## 📦 Installation 19 | 20 | ### macOS / Using Homebrew (easy) 21 | 22 | ```shell 23 | brew install s6n-jp/tap/ghr 24 | ``` 25 | 26 | To upgrade: 27 | 28 | ```shell 29 | brew upgrade s6n-jp/tap/ghr 30 | ``` 31 | 32 | ### Windows / Using scoop (easy) 33 | 34 | ```shell 35 | scoop bucket add siketyan https://github.com/siketyan/scoop-bucket.git 36 | scoop install ghr 37 | ``` 38 | 39 | To upgrade: 40 | 41 | ```shell 42 | scoop update ghr 43 | ``` 44 | 45 | ### Any OS / Using cargo (classic) 46 | 47 | If you have not installed Rust environment, follow the instruction of [rustup](https://rustup.rs/). 48 | 49 | ```shell 50 | cargo install ghr 51 | ``` 52 | 53 | For upgrading, we recommend to use [cargo-update](https://github.com/nabijaczleweli/cargo-update). 54 | 55 | ```shell 56 | cargo install-update ghr 57 | ``` 58 | 59 | ### 🔧 Installing the shell extension 60 | 61 | To extend ghr features to maximum, it is recommended to install the shell extension. 62 | Add the line below to your shell configuration script to enable it. 63 | 64 | #### Bash 65 | 66 | ```shell 67 | source <(ghr shell bash) 68 | ``` 69 | 70 | To enable completions, add the line into `~/.bash_completion`. 71 | 72 | ```shell 73 | source <(ghr shell bash --completion) 74 | ``` 75 | 76 | #### Fish 77 | 78 | ```shell 79 | ghr shell fish | source 80 | ``` 81 | 82 | To enable completions, add the line into `~/.config/fish/completions/ghr.fish`. 83 | 84 | ```shell 85 | ghr shell fish --completion | source 86 | ``` 87 | 88 | ## 💚 Usages 89 | 90 | ``` 91 | Usage: ghr 92 | 93 | Commands: 94 | cd Change directory to one of the managed repositories (Shell extension required) 95 | clone Clones a Git repository to local 96 | delete Deletes a repository from local 97 | init Initialises a Git repository in local 98 | list Lists all managed repositories 99 | open Opens a repository in an application 100 | browse Browse a repository on web 101 | path Prints the path to root, owner, or a repository 102 | profile Manages profiles to use in repositories 103 | search Perform a fuzzy search on the repositories list 104 | shell Writes a shell script to extend ghr features 105 | sync Sync repositories between your devices 106 | version Prints the version of this application 107 | help Print this message or the help of the given subcommand(s) 108 | 109 | Options: 110 | -q, --quiet Operates quietly. Errors will be reported even if this option is enabled 111 | -v, --verbose Operates verbosely. Traces, debug logs will be reported 112 | -h, --help Print help 113 | ``` 114 | 115 | ### Cloning a repository 116 | 117 | ghr supports many patterns or URLs of the repository to clone: 118 | 119 | ```shell 120 | ghr clone / 121 | ghr clone github.com:/ 122 | ghr clone https://github.com//.git 123 | ghr clone ssh://git@github.com//.git 124 | ghr clone git@github.com:/.git 125 | ``` 126 | 127 | If you have installed the shell extension, you can change directory to the cloned repository: 128 | 129 | ```shell 130 | ghr clone --cd 131 | ``` 132 | 133 | If you often use repositories of a specific owner, you can set the default owner to be resolved. 134 | 135 | ```toml 136 | [defaults] 137 | owner = "siketyan" 138 | ``` 139 | 140 | ```shell 141 | ghr clone 142 | ``` 143 | 144 | ### Changing directory 145 | 146 | You can change directory to one of the managed repositories on the shell. 147 | It requires installing the shell extension. 148 | 149 | ```shell 150 | ghr cd 151 | ``` 152 | 153 | ### Attaching profiles 154 | 155 | Create `~/.ghr/ghr.toml` and edit as you like: 156 | 157 | ```toml 158 | [profiles.default] 159 | user.name = "Your Name" 160 | user.email = "your_name@personal.example.com" 161 | 162 | [profiles.company] 163 | user.name = "Your Name (ACME Inc.)" 164 | user.email = "your_name@company.example.com" 165 | 166 | [[rules]] 167 | profile.name = "company" 168 | owner = "acme" # Applies company profiles to all repositories in `acme` org 169 | 170 | [[rules]] 171 | profile.name = "default" 172 | ``` 173 | 174 | ### Configuring applications to open repos in 175 | 176 | Edit `~/.ghr/ghr.toml` and add entries as you like: 177 | 178 | ```toml 179 | [applications.vscode] 180 | cmd = "code" 181 | args = ["%p"] 182 | ``` 183 | 184 | > [!NOTE] 185 | > `%p` will be replaced by the repository path. 186 | 187 | ### Finding path of the repository 188 | 189 | ```shell 190 | ghr path # Root directory 191 | ghr path / # Repository directory 192 | ghr path # Repository directory resolved by URL 193 | ghr path github.com// # Repository directory of the specified host 194 | ghr path --owner= # Owner root 195 | ghr path --host=github.com # Host root 196 | ghr path --host=github.com --owner= # Owner root of the specified host 197 | ``` 198 | 199 | ### Syncing repositories and their state 200 | 201 | > [!WARNING] 202 | > This feature is experimental. 203 | 204 | ghr supports dumping and restoring the current branch and remotes of managed repositories. 205 | 206 | ```shell 207 | ghr sync dump > repositories.toml 208 | ghr sync restore < repositories.toml 209 | ``` 210 | 211 | ## 🛠 Customising 212 | 213 | You can change the root of repositories managed by ghr by setting environment variable `GHR_ROOT` in your shell profile. 214 | 215 | ```shell 216 | ghr path # ~/.ghr 217 | GHR_ROOT=/path/to/root ghr path # /path/to/root 218 | ``` 219 | -------------------------------------------------------------------------------- /src/cmd/clone.rs: -------------------------------------------------------------------------------- 1 | use std::path::PathBuf; 2 | use std::sync::Arc; 3 | use std::time::Duration; 4 | 5 | use anyhow::{Result, anyhow}; 6 | use async_hofs::iter::AsyncMapExt; 7 | use clap::Parser; 8 | use console::style; 9 | use git2::Repository; 10 | use tokio::time::sleep; 11 | use tokio_stream::StreamExt; 12 | use tracing::{info, warn}; 13 | 14 | use crate::config::Config; 15 | use crate::console::{MultiSpinner, Spinner}; 16 | use crate::git::{CloneOptions, CloneRepository}; 17 | use crate::path::Path; 18 | use crate::root::Root; 19 | use crate::url::Url; 20 | 21 | // Constant values taken from implementation of GitHub Cli (gh) 22 | // ref: https://github.com/cli/cli/blob/350011/pkg/cmd/repo/fork/fork.go#L328-L344 23 | const CLONE_RETRY_COUNT: u32 = 3; 24 | const CLONE_RETRY_DURATION: Duration = Duration::from_secs(2); 25 | 26 | #[derive(Debug, Default, Parser)] 27 | pub struct Cmd { 28 | /// URL or pattern of the repository to clone. 29 | pub(crate) repo: Vec, 30 | 31 | /// Forks the repository in the specified owner (organisation) and clones the forked repo. 32 | #[clap(long)] 33 | pub(crate) fork: Option>, 34 | 35 | /// Clones multiple repositories in parallel. 36 | #[clap(short, long)] 37 | pub(crate) parallel: bool, 38 | 39 | /// Clones their submodules recursively. 40 | #[clap(short, long, alias = "recurse-submodules")] 41 | pub(crate) recursive: Option>, 42 | 43 | /// Clones only the default branch. 44 | #[clap(long)] 45 | pub(crate) single_branch: bool, 46 | 47 | /// Uses this name instead of `origin` for the upstream remote. 48 | #[clap(short, long)] 49 | pub(crate) origin: Option, 50 | 51 | /// Points the specified branch instead of the default branch after cloned the repository. 52 | #[clap(short, long)] 53 | pub(crate) branch: Option, 54 | 55 | /// Change directory after cloning the repository (Shell extension required). 56 | #[clap(long)] 57 | pub(crate) cd: bool, 58 | 59 | /// Opens the directory after cloning the repository. 60 | #[clap(long)] 61 | pub(crate) open: Option>, 62 | } 63 | 64 | impl Cmd { 65 | pub async fn run(self) -> Result<()> { 66 | let root = Root::find()?; 67 | let config = Config::load_from(&root)?; 68 | 69 | let urls = self 70 | .repo 71 | .iter() 72 | .async_map(|repo| self.url(&config, repo)) 73 | .collect::>>() 74 | .await?; 75 | 76 | match self.parallel { 77 | true => self.clone_parallel(root, config, urls).await?, 78 | _ => self.clone_serial(&root, &config, urls).await?, 79 | } 80 | .into_iter() 81 | .for_each( 82 | |CloneResult { 83 | path, 84 | profile, 85 | open, 86 | }| { 87 | info!( 88 | "Cloned a repository successfully to: {}", 89 | path.to_string_lossy(), 90 | ); 91 | 92 | if let Some(name) = profile { 93 | info!( 94 | "\t-> Attached profile [{}] successfully.", 95 | style(name).bold() 96 | ); 97 | } 98 | 99 | if let Some(app) = open { 100 | info!( 101 | "\t-> Opened the repository in [{}] successfully.", 102 | style(&app).bold(), 103 | ); 104 | } 105 | }, 106 | ); 107 | 108 | Ok(()) 109 | } 110 | 111 | async fn url(&self, config: &Config, repo: &str) -> Result { 112 | let mut url = Url::from_str(repo, &config.patterns, config.defaults.owner.as_deref())?; 113 | 114 | if let Some(owner) = &self.fork { 115 | info!("Forking from '{}'", url.to_string()); 116 | 117 | let platform = config 118 | .platforms 119 | .find(&url) 120 | .ok_or_else(|| anyhow!("Could not find a platform to fork on."))? 121 | .try_into_platform()?; 122 | 123 | url = Url::from_str( 124 | &platform.fork(&url, owner.clone()).await?, 125 | &config.patterns, 126 | config.defaults.owner.as_deref(), 127 | )?; 128 | } 129 | 130 | Ok(url) 131 | } 132 | 133 | async fn clone_serial( 134 | self, 135 | root: &Root, 136 | config: &Config, 137 | urls: Vec, 138 | ) -> Result> { 139 | Spinner::new("Cloning the repository...") 140 | .spin_while(|| async move { 141 | urls.into_iter() 142 | .async_map(|url| async { 143 | info!("Cloning from '{}'", url.to_string()); 144 | self.clone(root, config, url).await 145 | }) 146 | .collect::>>() 147 | .await 148 | }) 149 | .await 150 | } 151 | 152 | async fn clone_parallel( 153 | self, 154 | root: Root, 155 | config: Config, 156 | urls: Vec, 157 | ) -> Result> { 158 | let this = Arc::new(self); 159 | let root = Arc::new(root); 160 | let config = Arc::new(config); 161 | 162 | let mut spinner = MultiSpinner::new(); 163 | for url in urls { 164 | let this = Arc::clone(&this); 165 | let root = Arc::clone(&root); 166 | let config = Arc::clone(&config); 167 | 168 | spinner = spinner 169 | .with_spin_while(format!("Cloning from {}...", &url), move || async move { 170 | this.as_ref().clone(&root, &config, url).await 171 | }); 172 | } 173 | 174 | Ok(spinner.collect().await?.into_iter().collect()) 175 | } 176 | 177 | async fn clone(&self, root: &Root, config: &Config, url: Url) -> Result { 178 | let path = PathBuf::from(Path::resolve(root, &url)); 179 | let profile = config 180 | .rules 181 | .resolve(&url) 182 | .and_then(|r| config.profiles.resolve(&r.profile)); 183 | 184 | if path.exists() { 185 | warn!("Directory already exists. Skipping cloning the repository..."); 186 | } else { 187 | let mut retries = 0; 188 | while let Err(e) = config.git.strategy.clone.clone_repository( 189 | url.clone(), 190 | &path, 191 | &CloneOptions { 192 | recursive: self.recursive.clone(), 193 | single_branch: self.single_branch, 194 | origin: self.origin.clone(), 195 | branch: self.branch.clone(), 196 | }, 197 | ) { 198 | retries += 1; 199 | if self.fork.is_none() || retries > CLONE_RETRY_COUNT { 200 | return Err(e); 201 | } else { 202 | warn!( 203 | "Cloning failed. Retrying in {} seconds", 204 | CLONE_RETRY_DURATION.as_secs(), 205 | ); 206 | sleep(CLONE_RETRY_DURATION).await; 207 | } 208 | } 209 | } 210 | 211 | let repo = Repository::open(&path)?; 212 | let profile = if let Some((name, p)) = profile { 213 | p.apply(&repo)?; 214 | Some(name.to_string()) 215 | } else { 216 | None 217 | }; 218 | 219 | let open = if let Some(app) = &self.open { 220 | config 221 | .applications 222 | .open_or_intermediate_or_default(app.as_deref(), &path)?; 223 | 224 | Some(app.as_deref().unwrap_or("").to_string()) 225 | } else { 226 | None 227 | }; 228 | 229 | Ok(CloneResult { 230 | path: repo.workdir().unwrap().to_path_buf(), 231 | profile, 232 | open, 233 | }) 234 | } 235 | } 236 | 237 | struct CloneResult { 238 | path: PathBuf, 239 | profile: Option, 240 | open: Option, 241 | } 242 | -------------------------------------------------------------------------------- /src/url.rs: -------------------------------------------------------------------------------- 1 | use std::convert::Infallible; 2 | use std::fmt::{Display, Formatter}; 3 | use std::str::FromStr; 4 | 5 | use anyhow::{Error, Result, anyhow}; 6 | use itertools::FoldWhile; 7 | use itertools::Itertools; 8 | use regex::Regex; 9 | use serde::Deserialize; 10 | use serde_with::DeserializeFromStr; 11 | 12 | const GITHUB_COM: &str = "github.com"; 13 | 14 | const GIT_EXTENSION: &str = ".git"; 15 | const EXTENSIONS: &[&str] = &[GIT_EXTENSION]; 16 | 17 | macro_rules! lazy_static { 18 | ($(static ref $name:ident : $ty:ty = $value:expr ;)*) => { 19 | $( 20 | #[doc(hidden)] 21 | #[allow(dead_code, non_camel_case_types, clippy::upper_case_acronyms)] 22 | struct $name { __private: () } 23 | 24 | impl ::std::ops::Deref for $name { 25 | type Target = $ty; 26 | 27 | fn deref(&self) -> &Self::Target { 28 | use std::sync::OnceLock; 29 | static LAZY: OnceLock<$ty> = OnceLock::new(); 30 | LAZY.get_or_init(|| $value) 31 | } 32 | } 33 | 34 | #[allow(non_camel_case_types)] 35 | const $name: $name = $name { __private: () }; 36 | )* 37 | }; 38 | } 39 | 40 | lazy_static! { 41 | static ref SSH: Pattern = Pattern::from( 42 | Regex::new(r"^(?P[0-9A-Za-z\-]+)@(?P[0-9A-Za-z\.\-]+):(?P[0-9A-Za-z_\.\-]+)/(?P[0-9A-Za-z_\.\-]+)$") 43 | .unwrap(), 44 | ) 45 | .with_scheme(Scheme::Ssh) 46 | .with_infer(); 47 | 48 | static ref HOST_ORG_REPO: Pattern = Pattern::from( 49 | Regex::new(r"^(?P[0-9A-Za-z\.\-]+)[:/](?P[0-9A-Za-z_\.\-]+)/(?P[0-9A-Za-z_\.\-]+)$") 50 | .unwrap(), 51 | ) 52 | .with_infer(); 53 | 54 | static ref ORG_REPO: Pattern = Pattern::from( 55 | Regex::new(r"^(?P[0-9A-Za-z_\.\-]+)/(?P[0-9A-Za-z_\.\-]+)$") 56 | .unwrap(), 57 | ) 58 | .with_infer(); 59 | 60 | static ref REPO: Pattern = Pattern::from( 61 | Regex::new(r"^(?P[0-9A-Za-z_\.\-]+)$") 62 | .unwrap(), 63 | ) 64 | .with_infer(); 65 | } 66 | 67 | #[derive(Debug)] 68 | pub struct Match { 69 | pub vcs: Option, 70 | pub scheme: Option, 71 | pub user: Option, 72 | pub host: Option, 73 | pub owner: Option, 74 | pub repo: String, 75 | pub raw: Option, 76 | } 77 | 78 | #[derive(Debug, Clone, Deserialize)] 79 | pub struct Pattern { 80 | #[serde(with = "serde_regex")] 81 | regex: Regex, 82 | vcs: Option, 83 | scheme: Option, 84 | user: Option, 85 | host: Option, 86 | owner: Option, 87 | url: Option, 88 | infer: Option, 89 | } 90 | 91 | impl Pattern { 92 | #[inline] 93 | pub fn with_scheme(mut self, s: Scheme) -> Self { 94 | self.scheme = Some(s); 95 | self 96 | } 97 | 98 | #[inline] 99 | pub fn with_infer(mut self) -> Self { 100 | self.infer = Some(true); 101 | self 102 | } 103 | 104 | pub fn matches(&self, s: &str) -> Option { 105 | self.regex.captures(s).and_then(|c| { 106 | let repo = match c.name("repo") { 107 | Some(v) => v.as_str().to_string(), 108 | _ => return None, 109 | }; 110 | 111 | let mut m = Match { 112 | vcs: c 113 | .name("vcs") 114 | .and_then(|v| Vcs::from_str(v.as_str()).ok()) 115 | .or(self.vcs), 116 | scheme: c 117 | .name("scheme") 118 | .and_then(|v| Scheme::from_str(v.as_str()).ok()) 119 | .or(self.scheme), 120 | user: c 121 | .name("user") 122 | .map(|v| v.as_str().to_string()) 123 | .or(self.user.clone()), 124 | host: c 125 | .name("host") 126 | .and_then(|v| Host::from_str(v.as_str()).ok()) 127 | .or(self.host.clone()), 128 | owner: c 129 | .name("owner") 130 | .map(|v| v.as_str().to_string()) 131 | .or(self.owner.clone()), 132 | repo, 133 | raw: None, 134 | }; 135 | 136 | m.raw = match self.infer.unwrap_or_default() { 137 | true => None, 138 | _ => self 139 | .url 140 | .as_ref() 141 | .map(|u| { 142 | u.replace("{{vcs}}", &m.vcs.map(|v| v.to_string()).unwrap_or_default()) 143 | .replace( 144 | "{{scheme}}", 145 | &m.scheme.map(|s| s.to_string()).unwrap_or_default(), 146 | ) 147 | .replace("{{user}}", &m.user.clone().unwrap_or_default()) 148 | .replace( 149 | "{{host}}", 150 | &m.host.clone().map(|h| h.to_string()).unwrap_or_default(), 151 | ) 152 | .replace("{{owner}}", &m.owner.clone().unwrap_or_default()) 153 | .replace("{{repo}}", &m.repo) 154 | }) 155 | .or(Some(s.to_string())), 156 | }; 157 | 158 | Some(m) 159 | }) 160 | } 161 | } 162 | 163 | impl From for Pattern { 164 | fn from(value: Regex) -> Self { 165 | Self { 166 | regex: value, 167 | vcs: None, 168 | scheme: None, 169 | user: None, 170 | host: None, 171 | owner: None, 172 | url: None, 173 | infer: None, 174 | } 175 | } 176 | } 177 | 178 | #[derive(Debug, Deserialize)] 179 | pub struct Patterns(Vec); 180 | 181 | impl Patterns { 182 | pub fn new() -> Self { 183 | Self(Vec::new()) 184 | } 185 | 186 | #[inline] 187 | pub fn add(&mut self, p: Pattern) { 188 | self.0.push(p); 189 | } 190 | 191 | #[inline] 192 | pub fn with(mut self, p: Pattern) -> Self { 193 | self.add(p); 194 | self 195 | } 196 | 197 | pub fn with_defaults(self) -> Self { 198 | self.with(SSH.clone()) 199 | .with(HOST_ORG_REPO.clone()) 200 | .with(ORG_REPO.clone()) 201 | .with(REPO.clone()) 202 | } 203 | 204 | pub fn matches(&self, s: &str) -> Option { 205 | self.0.iter().find_map(|p| p.matches(s)) 206 | } 207 | } 208 | 209 | impl Default for Patterns { 210 | fn default() -> Self { 211 | Self::new().with_defaults() 212 | } 213 | } 214 | 215 | #[derive(Debug, Copy, Clone, Default, Eq, PartialEq, DeserializeFromStr)] 216 | pub enum Vcs { 217 | #[default] 218 | Git, 219 | } 220 | 221 | impl Vcs { 222 | fn from_url(url: &url::Url) -> Self { 223 | let url = url.as_str(); 224 | if url.ends_with(GIT_EXTENSION) { 225 | Self::Git 226 | } else { 227 | Default::default() 228 | } 229 | } 230 | 231 | fn extension(&self) -> &'static str { 232 | match self { 233 | Self::Git => GIT_EXTENSION, 234 | } 235 | } 236 | } 237 | 238 | impl FromStr for Vcs { 239 | type Err = Error; 240 | 241 | fn from_str(s: &str) -> std::result::Result { 242 | Ok(match s.to_ascii_lowercase().as_str() { 243 | "git" => Self::Git, 244 | _ => Err(anyhow!("Unknown VCS found: {}", s))?, 245 | }) 246 | } 247 | } 248 | 249 | impl Display for Vcs { 250 | fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { 251 | match self { 252 | Self::Git => write!(f, "git"), 253 | } 254 | } 255 | } 256 | 257 | #[derive(Debug, Copy, Clone, Default, Eq, PartialEq, DeserializeFromStr)] 258 | pub enum Scheme { 259 | #[default] 260 | Https, 261 | Ssh, 262 | } 263 | 264 | impl FromStr for Scheme { 265 | type Err = Error; 266 | 267 | fn from_str(s: &str) -> Result { 268 | Ok(match s.to_ascii_lowercase().as_str() { 269 | "https" => Self::Https, 270 | "ssh" => Self::Ssh, 271 | _ => Err(anyhow!("Unknown URL scheme found: {}", s))?, 272 | }) 273 | } 274 | } 275 | 276 | impl Display for Scheme { 277 | fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { 278 | match self { 279 | Self::Https => write!(f, "https"), 280 | Self::Ssh => write!(f, "ssh"), 281 | } 282 | } 283 | } 284 | 285 | #[derive(Debug, Clone, Default, Eq, PartialEq, DeserializeFromStr)] 286 | pub enum Host { 287 | #[default] 288 | GitHub, 289 | Unknown(String), 290 | } 291 | 292 | impl FromStr for Host { 293 | type Err = Infallible; 294 | 295 | fn from_str(s: &str) -> std::result::Result { 296 | Ok(match s.to_ascii_lowercase().as_str() { 297 | GITHUB_COM => Self::GitHub, 298 | _ => Self::Unknown(s.to_string()), 299 | }) 300 | } 301 | } 302 | 303 | impl Display for Host { 304 | fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { 305 | match self { 306 | Self::GitHub => write!(f, "{}", GITHUB_COM), 307 | Self::Unknown(s) => write!(f, "{}", s), 308 | } 309 | } 310 | } 311 | 312 | #[derive(Debug, Default, Eq, PartialEq, Clone)] 313 | pub struct Url { 314 | pub vcs: Vcs, 315 | pub scheme: Scheme, 316 | pub user: Option, 317 | pub host: Host, 318 | pub owner: String, 319 | pub repo: String, 320 | pub raw: Option, 321 | } 322 | 323 | impl Url { 324 | pub fn from_str(s: &str, p: &Patterns, default_owner: Option<&str>) -> Result { 325 | Self::from_pattern(s, p, default_owner).or_else(|e| match s.contains("://") { 326 | true => Self::from_url(&url::Url::from_str(s)?), 327 | _ => Err(e), 328 | }) 329 | } 330 | 331 | fn from_url(url: &url::Url) -> Result { 332 | let mut segments = url 333 | .path_segments() 334 | .ok_or_else(|| anyhow!("Could not parse path segments from the URL: {}", url))?; 335 | 336 | let scheme = Scheme::from_str(url.scheme())?; 337 | 338 | Ok(Self { 339 | vcs: Vcs::from_url(url), 340 | scheme, 341 | user: match url.username().is_empty() { 342 | true => None, 343 | _ => Some(url.username().to_string()), 344 | }, 345 | host: Host::from_str( 346 | url.host_str() 347 | .ok_or_else(|| anyhow!("Could not find hostname from the URL: {}", url))?, 348 | )?, 349 | owner: segments 350 | .next() 351 | .ok_or_else(|| anyhow!("Could not find owner from the URL: {}", url))? 352 | .to_string(), 353 | repo: Self::remove_extensions( 354 | segments.next().ok_or_else(|| { 355 | anyhow!("Could not find repository name from the URL: {}", url) 356 | })?, 357 | ), 358 | raw: match scheme { 359 | // HTTPS URLs can be used directly on cloning, so we prefer it than inferred one. 360 | // SSH URLs are not; Git only accepts 'git@github.com:org/repo.git' style. 361 | Scheme::Https => Some(url.to_string()), 362 | _ => None, 363 | }, 364 | }) 365 | } 366 | 367 | fn from_match(m: Match, default_owner: Option<&str>) -> Option { 368 | Some(Self { 369 | vcs: m.vcs.unwrap_or_default(), 370 | scheme: m.scheme.unwrap_or_default(), 371 | user: m.user, 372 | host: m.host.unwrap_or_default(), 373 | owner: m.owner.or_else(|| default_owner.map(|s| s.to_string()))?, 374 | repo: Self::remove_extensions(&m.repo), 375 | raw: m.raw, 376 | }) 377 | } 378 | 379 | fn from_pattern(s: &str, p: &Patterns, default_owner: Option<&str>) -> Result { 380 | p.matches(s) 381 | .and_then(|m| Self::from_match(m, default_owner)) 382 | .ok_or(anyhow!("The input did not match any pattern: {}", s)) 383 | } 384 | 385 | fn remove_extensions(s: &str) -> String { 386 | EXTENSIONS 387 | .iter() 388 | .fold_while(s.to_string(), |v, i| { 389 | let trimmed = v.trim_end_matches(i); 390 | match trimmed != v.as_str() { 391 | true => FoldWhile::Done(trimmed.to_string()), 392 | _ => FoldWhile::Continue(v), 393 | } 394 | }) 395 | .into_inner() 396 | } 397 | } 398 | 399 | impl Display for Url { 400 | fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { 401 | if let Some(r) = &self.raw { 402 | return write!(f, "{}", r); 403 | } 404 | 405 | let authority = match &self.user { 406 | Some(u) => format!("{}@{}", u, &self.host), 407 | _ => self.host.to_string(), 408 | }; 409 | 410 | match self.scheme { 411 | Scheme::Https => { 412 | write!( 413 | f, 414 | "https://{}/{}/{}{}", 415 | authority, 416 | self.owner, 417 | self.repo, 418 | self.vcs.extension(), 419 | ) 420 | } 421 | Scheme::Ssh => { 422 | write!( 423 | f, 424 | "{}:{}/{}{}", 425 | authority, 426 | self.owner, 427 | self.repo, 428 | self.vcs.extension(), 429 | ) 430 | } 431 | } 432 | } 433 | } 434 | 435 | #[cfg(test)] 436 | mod tests { 437 | use super::*; 438 | 439 | #[test] 440 | fn parse_from_url_https() { 441 | let url = url::Url::parse("https://github.com/siketyan/siketyan.github.io.git").unwrap(); 442 | 443 | assert_eq!( 444 | Url { 445 | vcs: Vcs::Git, 446 | scheme: Scheme::Https, 447 | user: None, 448 | host: Host::GitHub, 449 | owner: "siketyan".to_string(), 450 | repo: "siketyan.github.io".to_string(), 451 | raw: Some("https://github.com/siketyan/siketyan.github.io.git".to_string()), 452 | }, 453 | Url::from_url(&url).unwrap(), 454 | ) 455 | } 456 | 457 | #[test] 458 | fn parse_from_url_ssh() { 459 | let url = url::Url::parse("ssh://git@github.com/siketyan/siketyan.github.io.git").unwrap(); 460 | 461 | assert_eq!( 462 | Url { 463 | vcs: Vcs::Git, 464 | scheme: Scheme::Ssh, 465 | user: Some("git".to_string()), 466 | host: Host::GitHub, 467 | owner: "siketyan".to_string(), 468 | repo: "siketyan.github.io".to_string(), 469 | ..Default::default() 470 | }, 471 | Url::from_url(&url).unwrap(), 472 | ) 473 | } 474 | 475 | #[test] 476 | fn parse_from_pattern_repo() { 477 | assert_eq!( 478 | Url { 479 | vcs: Vcs::Git, 480 | scheme: Scheme::Https, 481 | user: None, 482 | host: Host::GitHub, 483 | owner: "siketyan".to_string(), 484 | repo: "siketyan.github.io".to_string(), 485 | ..Default::default() 486 | }, 487 | Url::from_pattern("siketyan.github.io", &Patterns::default(), Some("siketyan")) 488 | .unwrap(), 489 | ) 490 | } 491 | 492 | #[test] 493 | fn parse_from_pattern_org_repo() { 494 | assert_eq!( 495 | Url { 496 | vcs: Vcs::Git, 497 | scheme: Scheme::Https, 498 | user: None, 499 | host: Host::GitHub, 500 | owner: "siketyan".to_string(), 501 | repo: "siketyan.github.io".to_string(), 502 | ..Default::default() 503 | }, 504 | Url::from_pattern("siketyan/siketyan.github.io", &Patterns::default(), None).unwrap(), 505 | ) 506 | } 507 | 508 | #[test] 509 | fn parse_from_pattern_host_org_repo() { 510 | assert_eq!( 511 | Url { 512 | vcs: Vcs::Git, 513 | scheme: Scheme::Https, 514 | user: None, 515 | host: Host::Unknown("gitlab.com".to_string()), 516 | owner: "siketyan".to_string(), 517 | repo: "siketyan.github.io".to_string(), 518 | ..Default::default() 519 | }, 520 | Url::from_pattern( 521 | "gitlab.com:siketyan/siketyan.github.io", 522 | &Patterns::default(), 523 | None 524 | ) 525 | .unwrap(), 526 | ) 527 | } 528 | 529 | #[test] 530 | fn parse_from_pattern_ssh() { 531 | assert_eq!( 532 | Url { 533 | vcs: Vcs::Git, 534 | scheme: Scheme::Ssh, 535 | user: Some("git".to_string()), 536 | host: Host::GitHub, 537 | owner: "siketyan".to_string(), 538 | repo: "siketyan.github.io".to_string(), 539 | ..Default::default() 540 | }, 541 | Url::from_pattern( 542 | "git@github.com:siketyan/siketyan.github.io.git", 543 | &Patterns::default(), 544 | None 545 | ) 546 | .unwrap(), 547 | ) 548 | } 549 | 550 | #[test] 551 | fn parse_from_custom_pattern() { 552 | let patterns = Patterns::default().with( 553 | Pattern::from( 554 | Regex::new(r"^(?Phttps)://(?Pgit\.kernel\.org)/pub/scm/linux/kernel/git/(?P.+)/(?P.+)\.git").unwrap() 555 | ) 556 | .with_scheme(Scheme::Https) 557 | ); 558 | 559 | assert_eq!( 560 | Url { 561 | vcs: Vcs::Git, 562 | scheme: Scheme::Https, 563 | host: Host::Unknown("git.kernel.org".to_string()), 564 | owner: "torvalds".to_string(), 565 | repo: "linux".to_string(), 566 | raw: Some( 567 | "https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git" 568 | .to_string(), 569 | ), 570 | ..Default::default() 571 | }, 572 | Url::from_pattern( 573 | "https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git", 574 | &patterns, 575 | None 576 | ) 577 | .unwrap(), 578 | ) 579 | } 580 | 581 | #[test] 582 | fn to_string_https() { 583 | assert_eq!( 584 | "https://github.com/siketyan/siketyan.github.io.git", 585 | Url { 586 | vcs: Vcs::Git, 587 | scheme: Scheme::Https, 588 | user: None, 589 | host: Host::GitHub, 590 | owner: "siketyan".to_string(), 591 | repo: "siketyan.github.io".to_string(), 592 | ..Default::default() 593 | } 594 | .to_string() 595 | .as_str(), 596 | ) 597 | } 598 | 599 | #[test] 600 | fn to_string_ssh() { 601 | assert_eq!( 602 | "git@github.com:siketyan/siketyan.github.io.git", 603 | Url { 604 | vcs: Vcs::Git, 605 | scheme: Scheme::Ssh, 606 | user: Some("git".to_string()), 607 | host: Host::GitHub, 608 | owner: "siketyan".to_string(), 609 | repo: "siketyan.github.io".to_string(), 610 | ..Default::default() 611 | } 612 | .to_string() 613 | .as_str(), 614 | ) 615 | } 616 | } 617 | --------------------------------------------------------------------------------