diff --git a/README.md b/README.md index 6514414..48eeba6 100644 --- a/README.md +++ b/README.md @@ -89,6 +89,7 @@ Commands: path Prints the path to root, owner, or a repository profile Manages profiles to use in repositories shell Writes a shell script to extend ghr features + sync Sync repositories between your devices version Prints the version of this application help Print this message or the help of the given subcommand(s) @@ -163,7 +164,7 @@ cmd = "code" args = ["%p"] ``` -> **Note** +> [!NOTE] > `%p` will be replaced by the repository path. ### Finding path of the repository @@ -177,6 +178,18 @@ ghr path --host=github.com # Host root ghr path --host=github.com --owner= # Owner root of the specified host ``` +### Syncing repositories and their state + +> [!WARNING] +> This feature is experimental. + +ghr supports dumping and restoring the current branch and remotes of managed repositories. + +```shell +ghr sync dump > repositories.toml +ghr sync restore < repositories.toml +``` + ## 🛠 Customising You can change the root of repositories managed by ghr by setting environment variable `GHR_ROOT` in your shell profile. diff --git a/src/cmd/clone.rs b/src/cmd/clone.rs index f56a83e..5aa0a91 100644 --- a/src/cmd/clone.rs +++ b/src/cmd/clone.rs @@ -23,42 +23,42 @@ use crate::url::Url; const CLONE_RETRY_COUNT: u32 = 3; const CLONE_RETRY_DURATION: Duration = Duration::from_secs(2); -#[derive(Debug, Parser)] +#[derive(Debug, Default, Parser)] pub struct Cmd { /// URL or pattern of the repository to clone. - repo: Vec, + pub(crate) repo: Vec, /// Forks the repository in the specified owner (organisation) and clones the forked repo. #[clap(long)] - fork: Option>, + pub(crate) fork: Option>, /// Clones multiple repositories in parallel. #[clap(short, long)] - parallel: bool, + pub(crate) parallel: bool, /// Clones their submodules recursively. #[clap(short, long, alias = "recurse-submodules")] - recursive: Option>, + pub(crate) recursive: Option>, /// Clones only the default branch. #[clap(long)] - single_branch: bool, + pub(crate) single_branch: bool, /// Uses this name instead of `origin` for the upstream remote. #[clap(short, long)] - origin: Option, + pub(crate) origin: Option, /// Points the specified branch instead of the default branch after cloned the repository. #[clap(short, long)] - branch: Option, + pub(crate) branch: Option, /// Change directory after cloning the repository (Shell extension required). #[clap(long)] - cd: bool, + pub(crate) cd: bool, /// Opens the directory after cloning the repository. #[clap(long)] - open: Option>, + pub(crate) open: Option>, } impl Cmd { diff --git a/src/cmd/mod.rs b/src/cmd/mod.rs index 241b128..febb66d 100644 --- a/src/cmd/mod.rs +++ b/src/cmd/mod.rs @@ -9,6 +9,7 @@ mod open; mod path; mod profile; mod shell; +mod sync; mod version; use std::io::stderr; @@ -42,6 +43,8 @@ pub enum Action { Profile(profile::Cmd), /// Writes a shell script to extend ghr features. Shell(shell::Cmd), + /// Sync repositories between your devices. + Sync(sync::Cmd), /// Prints the version of this application. Version(version::Cmd), } @@ -91,6 +94,7 @@ impl Cli { Path(cmd) => cmd.run(), Profile(cmd) => cmd.run(), Shell(cmd) => cmd.run(), + Sync(cmd) => cmd.run().await, Version(cmd) => cmd.run(), } } diff --git a/src/cmd/sync/dump.rs b/src/cmd/sync/dump.rs new file mode 100644 index 0000000..23da30d --- /dev/null +++ b/src/cmd/sync/dump.rs @@ -0,0 +1,25 @@ +use anyhow::Result; +use clap::Parser; +use itertools::Itertools; + +use crate::repository::Repositories; +use crate::root::Root; +use crate::sync::File; + +#[derive(Debug, Parser)] +pub struct Cmd {} + +impl Cmd { + pub fn run(self) -> Result<()> { + let root = Root::find()?; + let file = Repositories::try_collect(&root)? + .into_iter() + .map(|(p, _)| p) + .sorted_by_key(|p| p.to_string()) + .collect::(); + + println!("{}", toml::to_string(&file)?); + + Ok(()) + } +} diff --git a/src/cmd/sync/mod.rs b/src/cmd/sync/mod.rs new file mode 100644 index 0000000..902f149 --- /dev/null +++ b/src/cmd/sync/mod.rs @@ -0,0 +1,29 @@ +mod dump; +mod restore; + +use anyhow::Result; +use clap::{Parser, Subcommand}; + +#[derive(Debug, Subcommand)] +pub enum Action { + /// Dump remotes and the current ref of all repositories. + Dump(dump::Cmd), + /// Restore repositories from the dumped file. + Restore(restore::Cmd), +} + +#[derive(Debug, Parser)] +pub struct Cmd { + #[clap(subcommand)] + action: Action, +} + +impl Cmd { + pub async fn run(self) -> Result<()> { + use Action::*; + match self.action { + Dump(cmd) => cmd.run(), + Restore(cmd) => cmd.run().await, + } + } +} diff --git a/src/cmd/sync/restore.rs b/src/cmd/sync/restore.rs new file mode 100644 index 0000000..e261279 --- /dev/null +++ b/src/cmd/sync/restore.rs @@ -0,0 +1,97 @@ +use std::io::{read_to_string, stdin}; +use std::path::PathBuf; + +use anyhow::Result; +use clap::Parser; +use git2::{ErrorCode, Repository as GitRepository}; +use tracing::info; + +use crate::cmd::clone; +use crate::config::Config; +use crate::console::Spinner; +use crate::git::{CheckoutBranch, Fetch}; +use crate::path::Path; +use crate::root::Root; +use crate::sync::{File, Ref, Repository}; +use crate::url::Url; + +#[derive(Debug, Parser)] +pub struct Cmd {} + +impl Cmd { + pub async fn run(self) -> Result<()> { + let root = Root::find()?; + let config = Config::load_from(&root)?; + let file = toml::from_str::(read_to_string(stdin())?.as_str())?; + + for Repository { + host, + owner, + repo, + r#ref, + remotes, + } in file.repositories + { + let origin = remotes.iter().find(|r| { + Url::from_str(&r.url, &config.patterns, config.defaults.owner.as_deref()) + .ok() + .map(|u| u.host.to_string() == host) + .unwrap_or_default() + }); + + clone::Cmd { + repo: vec![origin + .map(|r| r.url.to_string()) + .unwrap_or_else(|| format!("{}:{}/{}", host, owner, repo))], + origin: origin.map(|r| r.name.to_string()), + ..Default::default() + } + .run() + .await?; + + let path = PathBuf::from(Path::new(&root, host, owner, repo)); + let repo = GitRepository::open(&path)?; + + for remote in remotes { + if let Err(e) = repo + .remote(&remote.name, &remote.url) + .and_then(|_| repo.remote_set_pushurl(&remote.name, remote.push_url.as_deref())) + { + match e.code() { + ErrorCode::Exists => (), + _ => return Err(e.into()), + } + } + + Spinner::new("Fetching objects from remotes...") + .spin_while(|| async { + config.git.strategy.clone.fetch(&path, &remote.name)?; + Ok::<(), anyhow::Error>(()) + }) + .await?; + + info!("Fetched from remote: {}", &remote.name); + } + + match r#ref { + Some(Ref::Remote(r)) => { + repo.checkout_tree(&repo.revparse_single(&r)?, None)?; + + info!("Successfully checked out a remote ref: {}", &r); + } + Some(Ref::Branch(b)) => { + config.git.strategy.checkout.checkout_branch( + &path, + &b.name, + Some(b.upstream.to_string()), + )?; + + info!("Successfully checked out a branch: {}", &b.name); + } + _ => (), + } + } + + Ok(()) + } +} diff --git a/src/git/config.rs b/src/git/config.rs index 64570d4..493ff1f 100644 --- a/src/git/config.rs +++ b/src/git/config.rs @@ -6,6 +6,10 @@ use crate::git::strategy::Strategy; pub struct StrategyConfig { #[serde(default)] pub clone: Strategy, + #[serde(default)] + pub fetch: Strategy, + #[serde(default)] + pub checkout: Strategy, } #[derive(Debug, Default, Deserialize)] diff --git a/src/git/mod.rs b/src/git/mod.rs index af401ee..40a2417 100644 --- a/src/git/mod.rs +++ b/src/git/mod.rs @@ -21,3 +21,20 @@ pub trait CloneRepository { U: ToString, P: AsRef; } + +pub trait Fetch { + fn fetch

(&self, path: P, remote: impl Into) -> Result<()> + where + P: AsRef; +} + +pub trait CheckoutBranch { + fn checkout_branch

( + &self, + path: P, + branch: impl Into, + track: impl Into>, + ) -> Result<()> + where + P: AsRef; +} diff --git a/src/git/strategy/cli.rs b/src/git/strategy/cli.rs index ac60a1b..d8ea132 100644 --- a/src/git/strategy/cli.rs +++ b/src/git/strategy/cli.rs @@ -4,7 +4,7 @@ use std::process::Command; use anyhow::anyhow; use tracing::debug; -use crate::git::{CloneOptions, CloneRepository}; +use crate::git::{CheckoutBranch, CloneOptions, CloneRepository, Fetch}; pub struct Cli; @@ -48,3 +48,50 @@ impl CloneRepository for Cli { } } } + +impl Fetch for Cli { + fn fetch

(&self, path: P, remote: impl Into) -> anyhow::Result<()> + where + P: AsRef, + { + let output = Command::new("git") + .current_dir(path) + .args(["fetch".to_string(), remote.into()]) + .output()?; + + match output.status.success() { + true => Ok(()), + _ => Err(anyhow!( + "Error occurred while fetching the remote: {}", + String::from_utf8_lossy(output.stderr.as_slice()).trim(), + )), + } + } +} + +impl CheckoutBranch for Cli { + fn checkout_branch

( + &self, + path: P, + branch: impl Into, + track: impl Into>, + ) -> anyhow::Result<()> + where + P: AsRef, + { + let mut args = Vec::from(["checkout".to_string(), "-b".to_string(), branch.into()]); + if let Some(t) = track.into() { + args.push("--track".to_string()); + args.push(t); + } + + let output = Command::new("git").current_dir(path).args(args).output()?; + match output.status.success() { + true => Ok(()), + _ => Err(anyhow!( + "Error occurred while fetching the remote: {}", + String::from_utf8_lossy(output.stderr.as_slice()).trim(), + )), + } + } +} diff --git a/src/git/strategy/mod.rs b/src/git/strategy/mod.rs index 580c612..83a13cc 100644 --- a/src/git/strategy/mod.rs +++ b/src/git/strategy/mod.rs @@ -6,7 +6,7 @@ use std::path::Path; use serde::Deserialize; -use crate::git::{CloneOptions, CloneRepository}; +use crate::git::{CheckoutBranch, CloneOptions, CloneRepository, Fetch}; #[derive(Debug, Default, Deserialize)] pub enum Strategy { @@ -25,3 +25,30 @@ impl CloneRepository for Strategy { } } } + +impl Fetch for Strategy { + fn fetch

(&self, path: P, remote: impl Into) -> anyhow::Result<()> + where + P: AsRef, + { + match self { + Self::Cli => Cli.fetch(path, remote), + } + } +} + +impl CheckoutBranch for Strategy { + fn checkout_branch

( + &self, + path: P, + branch: impl Into, + track: impl Into>, + ) -> anyhow::Result<()> + where + P: AsRef, + { + match self { + Self::Cli => Cli.checkout_branch(path, branch, track), + } + } +} diff --git a/src/main.rs b/src/main.rs index 5d7e264..21a714b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -9,6 +9,7 @@ mod profile; mod repository; mod root; mod rule; +mod sync; mod url; use std::process::exit; diff --git a/src/path.rs b/src/path.rs index 1f64529..2efe312 100644 --- a/src/path.rs +++ b/src/path.rs @@ -1,14 +1,15 @@ -use crate::root::Root; -use crate::url::Url; use std::fmt::{Display, Formatter}; use std::path::PathBuf; +use crate::root::Root; +use crate::url::Url; + #[derive(Debug, Eq, PartialEq, Hash)] pub struct Path<'a> { root: &'a Root, - host: String, - owner: String, - repo: String, + pub host: String, + pub owner: String, + pub repo: String, } impl<'a> Path<'a> { diff --git a/src/sync.rs b/src/sync.rs new file mode 100644 index 0000000..2dfe53d --- /dev/null +++ b/src/sync.rs @@ -0,0 +1,162 @@ +use std::path::PathBuf; + +use anyhow::{bail, Result}; +use git2::{BranchType, ErrorCode, Reference, Repository as GitRepository}; +use serde::{Deserialize, Serialize}; +use tracing::warn; + +use crate::path::Path; + +#[derive(Deserialize, Serialize)] +pub struct BranchRef { + pub name: String, + pub upstream: String, +} + +#[derive(Deserialize, Serialize)] +#[serde(tag = "type")] +pub enum Ref { + #[serde(rename = "remote")] + Remote(String), + #[serde(rename = "branch")] + Branch(BranchRef), + // tag is not supported ... yet. +} + +#[derive(Deserialize, Serialize)] +pub struct Remote { + pub name: String, + pub url: String, + pub push_url: Option, +} + +#[derive(Deserialize, Serialize)] +pub struct Repository { + pub host: String, + pub owner: String, + pub repo: String, + pub r#ref: Option, + #[serde(default)] + pub remotes: Vec, +} + +impl Repository { + pub fn save(path: &Path) -> Result { + let repo = match GitRepository::open(PathBuf::from(path)) { + Ok(r) => r, + Err(e) => match e.code() { + ErrorCode::NotFound => bail!("Not a Git repository"), + _ => return Err(e.into()), + }, + }; + + let head = match repo.head() { + Ok(r) => r, + Err(e) => match e.code() { + ErrorCode::UnbornBranch => bail!("HEAD is an unborn branch"), + ErrorCode::NotFound => bail!("Cannot find the HEAD"), + _ => return Err(e.into()), + }, + }; + + let remotes = repo.remotes()?; + if remotes.is_empty() { + bail!("No remotes defined"); + } + + let r#ref = match Self::synced_ref(&repo, &head) { + Ok(r) => Some(r), + Err(e) => { + warn!("Repository {} is not synced to remote: {}", path, e); + None + } + }; + + Ok(Self { + host: path.host.to_string(), + owner: path.owner.to_string(), + repo: path.repo.to_string(), + r#ref, + remotes: remotes + .iter() + .flatten() + .map(|name| { + let remote = repo.find_remote(name)?; + + Ok(Remote { + name: name.to_string(), + url: remote.url().unwrap_or_default().to_string(), + push_url: remote.pushurl().map(|u| u.to_string()), + }) + }) + .collect::>>()?, + }) + } + + fn synced_ref(repo: &GitRepository, head: &Reference) -> Result { + if head.is_remote() { + return Ok(Ref::Remote(head.name().unwrap().to_string())); + } + + if head.is_branch() { + let name = head.shorthand().unwrap(); + let upstream = match repo.find_branch(name, BranchType::Local)?.upstream() { + Ok(b) => b, + Err(e) => match e.code() { + ErrorCode::NotFound => bail!("Branch has never pushed to remote"), + _ => return Err(e.into()), + }, + }; + + let upstream_name = upstream.name()?.unwrap().to_string(); + let reference = upstream.into_reference(); + if head != &reference { + bail!("Branch is not synced"); + } + + Ok(Ref::Branch(BranchRef { + name: name.to_string(), + upstream: upstream_name, + })) + } else if head.is_tag() { + bail!("HEAD is a tag"); + } else { + bail!("Detached HEAD"); + } + } +} + +#[derive(Deserialize, Serialize)] +pub enum Version { + #[serde(rename = "1")] + V1, +} + +#[derive(Deserialize, Serialize)] +pub struct File { + pub version: Version, + + #[serde(default)] + pub repositories: Vec, +} + +impl<'a> FromIterator> for File { + fn from_iter(iter: T) -> Self + where + T: IntoIterator>, + { + Self { + version: Version::V1, + repositories: iter + .into_iter() + .flat_map(|path| match Repository::save(&path) { + Ok(r) => Some(r), + Err(e) => { + warn!("Skipped repository {}: {}", &path, e); + None + } + }) + .collect::>(), + } + } +}