refactor(git): split mock, real and open into their files
This commit is contained in:
parent
7b1575eb09
commit
155497c97f
5 changed files with 245 additions and 249 deletions
|
@ -1,249 +0,0 @@
|
||||||
//
|
|
||||||
#![cfg(not(tarpaulin_include))]
|
|
||||||
use std::{
|
|
||||||
ops::Deref as _,
|
|
||||||
sync::{atomic::AtomicBool, Arc, Mutex},
|
|
||||||
};
|
|
||||||
|
|
||||||
use git_next_config::{BranchName, GitDir, Hostname, RepoPath};
|
|
||||||
use tracing::{info, warn};
|
|
||||||
|
|
||||||
use crate::{fetch, push, GitRef, GitRemote};
|
|
||||||
|
|
||||||
use super::RepoDetails;
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug)]
|
|
||||||
pub enum Repository {
|
|
||||||
Real,
|
|
||||||
Mock,
|
|
||||||
}
|
|
||||||
pub const fn new() -> Repository {
|
|
||||||
Repository::Real
|
|
||||||
}
|
|
||||||
pub const fn mock() -> Repository {
|
|
||||||
Repository::Mock
|
|
||||||
}
|
|
||||||
mod mock {
|
|
||||||
use super::*;
|
|
||||||
pub struct MockRepository;
|
|
||||||
impl RepositoryLike for MockRepository {
|
|
||||||
fn open(&self, _gitdir: &GitDir) -> Result<open::OpenRepository, Error> {
|
|
||||||
Ok(open::OpenRepository::Mock)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn git_clone(&self, _repo_details: &RepoDetails) -> Result<open::OpenRepository, Error> {
|
|
||||||
Ok(open::OpenRepository::Mock)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub trait RepositoryLike {
|
|
||||||
fn open(&self, gitdir: &GitDir) -> Result<open::OpenRepository, Error>;
|
|
||||||
fn git_clone(&self, repo_details: &RepoDetails) -> Result<open::OpenRepository, Error>;
|
|
||||||
}
|
|
||||||
impl std::ops::Deref for Repository {
|
|
||||||
type Target = dyn RepositoryLike;
|
|
||||||
|
|
||||||
fn deref(&self) -> &Self::Target {
|
|
||||||
match self {
|
|
||||||
Self::Real => &real::RealRepository,
|
|
||||||
Self::Mock => &mock::MockRepository,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
mod real {
|
|
||||||
use super::*;
|
|
||||||
pub struct RealRepository;
|
|
||||||
impl RepositoryLike for RealRepository {
|
|
||||||
fn open(&self, gitdir: &GitDir) -> Result<open::OpenRepository, Error> {
|
|
||||||
Ok(open::OpenRepository::Real(open::RealOpenRepository::new(
|
|
||||||
Arc::new(Mutex::new(
|
|
||||||
gix::ThreadSafeRepository::open(gitdir.to_path_buf())?.to_thread_local(),
|
|
||||||
)),
|
|
||||||
)))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn git_clone(&self, repo_details: &RepoDetails) -> Result<open::OpenRepository, Error> {
|
|
||||||
use secrecy::ExposeSecret;
|
|
||||||
let origin = repo_details.origin();
|
|
||||||
let (repository, _outcome) = gix::prepare_clone_bare(
|
|
||||||
origin.expose_secret().as_str(),
|
|
||||||
repo_details.gitdir.deref(),
|
|
||||||
)?
|
|
||||||
.fetch_only(gix::progress::Discard, &AtomicBool::new(false))?;
|
|
||||||
|
|
||||||
Ok(open::OpenRepository::Real(open::RealOpenRepository::new(
|
|
||||||
Arc::new(Mutex::new(repository)),
|
|
||||||
)))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub mod open {
|
|
||||||
use super::*;
|
|
||||||
#[derive(Clone, Debug)]
|
|
||||||
pub enum OpenRepository {
|
|
||||||
Real(RealOpenRepository),
|
|
||||||
Mock, // TODO: (#38) contain a mock model of a repo
|
|
||||||
}
|
|
||||||
pub trait OpenRepositoryLike {
|
|
||||||
fn find_default_remote(&self, direction: Direction) -> Option<GitRemote>;
|
|
||||||
fn fetch(&self) -> Result<(), fetch::Error>;
|
|
||||||
fn push(
|
|
||||||
&self,
|
|
||||||
repo_details: &RepoDetails,
|
|
||||||
branch_name: BranchName,
|
|
||||||
to_commit: GitRef,
|
|
||||||
force: push::Force,
|
|
||||||
) -> Result<(), push::Error>;
|
|
||||||
}
|
|
||||||
impl std::ops::Deref for OpenRepository {
|
|
||||||
type Target = dyn OpenRepositoryLike;
|
|
||||||
|
|
||||||
fn deref(&self) -> &Self::Target {
|
|
||||||
match self {
|
|
||||||
Self::Real(real) => real,
|
|
||||||
Self::Mock => todo!(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, derive_more::Constructor)]
|
|
||||||
pub struct RealOpenRepository(Arc<Mutex<gix::Repository>>);
|
|
||||||
impl OpenRepositoryLike for RealOpenRepository {
|
|
||||||
fn find_default_remote(&self, direction: Direction) -> Option<GitRemote> {
|
|
||||||
let Ok(repository) = self.0.lock() else {
|
|
||||||
return None;
|
|
||||||
};
|
|
||||||
let Some(Ok(remote)) = repository.find_default_remote(direction.into()) else {
|
|
||||||
return None;
|
|
||||||
};
|
|
||||||
let url = remote.url(direction.into())?;
|
|
||||||
let host = url.host()?;
|
|
||||||
let path = url.path.to_string();
|
|
||||||
let path = path.strip_prefix('/').map_or(path.as_str(), |path| path);
|
|
||||||
let path = path.strip_suffix(".git").map_or(path, |path| path);
|
|
||||||
Some(GitRemote::new(
|
|
||||||
Hostname::new(host),
|
|
||||||
RepoPath::new(path.to_string()),
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn fetch(&self) -> Result<(), fetch::Error> {
|
|
||||||
let Ok(repository) = self.0.lock() else {
|
|
||||||
return Err(fetch::Error::Lock);
|
|
||||||
};
|
|
||||||
let Some(Ok(remote)) = repository.find_default_remote(Direction::Fetch.into()) else {
|
|
||||||
return Err(fetch::Error::NoFetchRemoteFound);
|
|
||||||
};
|
|
||||||
remote
|
|
||||||
.connect(gix::remote::Direction::Fetch)
|
|
||||||
.map_err(|e| fetch::Error::Connect(e.to_string()))?
|
|
||||||
.prepare_fetch(gix::progress::Discard, Default::default())
|
|
||||||
.map_err(|e| fetch::Error::Fetch(e.to_string()))?
|
|
||||||
.receive(gix::progress::Discard, &Default::default())
|
|
||||||
.map_err(|e| fetch::Error::Fetch(e.to_string()))?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: (#72) reimplement using `gix`
|
|
||||||
fn push(
|
|
||||||
&self,
|
|
||||||
repo_details: &RepoDetails,
|
|
||||||
branch_name: BranchName,
|
|
||||||
to_commit: GitRef,
|
|
||||||
force: push::Force,
|
|
||||||
) -> Result<(), push::Error> {
|
|
||||||
let origin = repo_details.origin();
|
|
||||||
let force = match force {
|
|
||||||
push::Force::No => "".to_string(),
|
|
||||||
push::Force::From(old_ref) => format!("--force-with-lease={branch_name}:{old_ref}"),
|
|
||||||
};
|
|
||||||
// INFO: never log the command as it contains the API token within the 'origin'
|
|
||||||
use secrecy::ExposeSecret;
|
|
||||||
let command: secrecy::Secret<String> = format!(
|
|
||||||
"/usr/bin/git push {} {to_commit}:{branch_name} {force}",
|
|
||||||
origin.expose_secret()
|
|
||||||
)
|
|
||||||
.into();
|
|
||||||
|
|
||||||
let git_dir = self
|
|
||||||
.0
|
|
||||||
.lock()
|
|
||||||
.map_err(|_| push::Error::Lock)
|
|
||||||
.map(|r| r.git_dir().to_path_buf())?;
|
|
||||||
|
|
||||||
let ctx = gix::diff::command::Context {
|
|
||||||
git_dir: Some(git_dir.clone()),
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
match gix::command::prepare(command.expose_secret())
|
|
||||||
.with_context(ctx)
|
|
||||||
.with_shell_allow_argument_splitting()
|
|
||||||
.stdout(std::process::Stdio::null())
|
|
||||||
.stderr(std::process::Stdio::null())
|
|
||||||
.spawn()
|
|
||||||
{
|
|
||||||
Ok(mut child) => match child.wait() {
|
|
||||||
Ok(_) => {
|
|
||||||
info!("Branch updated");
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
Err(err) => {
|
|
||||||
warn!(?err, ?git_dir, "Failed (wait)");
|
|
||||||
Err(push::Error::Push)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
Err(err) => {
|
|
||||||
warn!(?err, ?git_dir, "Failed (spawn)");
|
|
||||||
Err(push::Error::Push)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Copy, Clone, PartialEq, Eq)]
|
|
||||||
pub enum Direction {
|
|
||||||
/// Push local changes to the remote.
|
|
||||||
Push,
|
|
||||||
/// Fetch changes from the remote to the local repository.
|
|
||||||
Fetch,
|
|
||||||
}
|
|
||||||
impl From<Direction> for gix::remote::Direction {
|
|
||||||
fn from(value: Direction) -> Self {
|
|
||||||
match value {
|
|
||||||
Direction::Push => Self::Push,
|
|
||||||
Direction::Fetch => Self::Fetch,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, derive_more::Display)]
|
|
||||||
pub enum Error {
|
|
||||||
InvalidGitDir(git_next_config::GitDir),
|
|
||||||
Io(std::io::Error),
|
|
||||||
Wait(std::io::Error),
|
|
||||||
Spawn(std::io::Error),
|
|
||||||
Validation(String),
|
|
||||||
GixClone(Box<gix::clone::Error>),
|
|
||||||
GixOpen(Box<gix::open::Error>),
|
|
||||||
GixFetch(Box<gix::clone::fetch::Error>),
|
|
||||||
}
|
|
||||||
impl std::error::Error for Error {}
|
|
||||||
impl From<gix::clone::Error> for Error {
|
|
||||||
fn from(value: gix::clone::Error) -> Self {
|
|
||||||
Self::GixClone(Box::new(value))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
impl From<gix::open::Error> for Error {
|
|
||||||
fn from(value: gix::open::Error) -> Self {
|
|
||||||
Self::GixOpen(Box::new(value))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
impl From<gix::clone::fetch::Error> for Error {
|
|
||||||
fn from(value: gix::clone::fetch::Error) -> Self {
|
|
||||||
Self::GixFetch(Box::new(value))
|
|
||||||
}
|
|
||||||
}
|
|
11
crates/git/src/repository/mock.rs
Normal file
11
crates/git/src/repository/mock.rs
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
use super::*;
|
||||||
|
pub struct MockRepository;
|
||||||
|
impl RepositoryLike for MockRepository {
|
||||||
|
fn open(&self, _gitdir: &GitDir) -> Result<open::OpenRepository, Error> {
|
||||||
|
Ok(open::OpenRepository::Mock)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn git_clone(&self, _repo_details: &RepoDetails) -> Result<open::OpenRepository, Error> {
|
||||||
|
Ok(open::OpenRepository::Mock)
|
||||||
|
}
|
||||||
|
}
|
89
crates/git/src/repository/mod.rs
Normal file
89
crates/git/src/repository/mod.rs
Normal file
|
@ -0,0 +1,89 @@
|
||||||
|
//
|
||||||
|
#![cfg(not(tarpaulin_include))]
|
||||||
|
|
||||||
|
mod mock;
|
||||||
|
pub mod open;
|
||||||
|
mod real;
|
||||||
|
|
||||||
|
use std::{
|
||||||
|
ops::Deref as _,
|
||||||
|
sync::{atomic::AtomicBool, Arc, Mutex},
|
||||||
|
};
|
||||||
|
|
||||||
|
use git_next_config::{BranchName, GitDir, Hostname, RepoPath};
|
||||||
|
use tracing::{info, warn};
|
||||||
|
|
||||||
|
use crate::{fetch, push, GitRef, GitRemote};
|
||||||
|
|
||||||
|
use super::RepoDetails;
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug)]
|
||||||
|
pub enum Repository {
|
||||||
|
Real,
|
||||||
|
Mock,
|
||||||
|
}
|
||||||
|
pub const fn new() -> Repository {
|
||||||
|
Repository::Real
|
||||||
|
}
|
||||||
|
pub const fn mock() -> Repository {
|
||||||
|
Repository::Mock
|
||||||
|
}
|
||||||
|
|
||||||
|
pub trait RepositoryLike {
|
||||||
|
fn open(&self, gitdir: &GitDir) -> Result<open::OpenRepository, Error>;
|
||||||
|
fn git_clone(&self, repo_details: &RepoDetails) -> Result<open::OpenRepository, Error>;
|
||||||
|
}
|
||||||
|
impl std::ops::Deref for Repository {
|
||||||
|
type Target = dyn RepositoryLike;
|
||||||
|
|
||||||
|
fn deref(&self) -> &Self::Target {
|
||||||
|
match self {
|
||||||
|
Self::Real => &real::RealRepository,
|
||||||
|
Self::Mock => &mock::MockRepository,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Copy, Clone, PartialEq, Eq)]
|
||||||
|
pub enum Direction {
|
||||||
|
/// Push local changes to the remote.
|
||||||
|
Push,
|
||||||
|
/// Fetch changes from the remote to the local repository.
|
||||||
|
Fetch,
|
||||||
|
}
|
||||||
|
impl From<Direction> for gix::remote::Direction {
|
||||||
|
fn from(value: Direction) -> Self {
|
||||||
|
match value {
|
||||||
|
Direction::Push => Self::Push,
|
||||||
|
Direction::Fetch => Self::Fetch,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, derive_more::Display)]
|
||||||
|
pub enum Error {
|
||||||
|
InvalidGitDir(git_next_config::GitDir),
|
||||||
|
Io(std::io::Error),
|
||||||
|
Wait(std::io::Error),
|
||||||
|
Spawn(std::io::Error),
|
||||||
|
Validation(String),
|
||||||
|
GixClone(Box<gix::clone::Error>),
|
||||||
|
GixOpen(Box<gix::open::Error>),
|
||||||
|
GixFetch(Box<gix::clone::fetch::Error>),
|
||||||
|
}
|
||||||
|
impl std::error::Error for Error {}
|
||||||
|
impl From<gix::clone::Error> for Error {
|
||||||
|
fn from(value: gix::clone::Error) -> Self {
|
||||||
|
Self::GixClone(Box::new(value))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl From<gix::open::Error> for Error {
|
||||||
|
fn from(value: gix::open::Error) -> Self {
|
||||||
|
Self::GixOpen(Box::new(value))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl From<gix::clone::fetch::Error> for Error {
|
||||||
|
fn from(value: gix::clone::fetch::Error) -> Self {
|
||||||
|
Self::GixFetch(Box::new(value))
|
||||||
|
}
|
||||||
|
}
|
122
crates/git/src/repository/open.rs
Normal file
122
crates/git/src/repository/open.rs
Normal file
|
@ -0,0 +1,122 @@
|
||||||
|
use super::*;
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub enum OpenRepository {
|
||||||
|
Real(RealOpenRepository),
|
||||||
|
Mock, // TODO: (#38) contain a mock model of a repo
|
||||||
|
}
|
||||||
|
pub trait OpenRepositoryLike {
|
||||||
|
fn find_default_remote(&self, direction: Direction) -> Option<GitRemote>;
|
||||||
|
fn fetch(&self) -> Result<(), fetch::Error>;
|
||||||
|
fn push(
|
||||||
|
&self,
|
||||||
|
repo_details: &RepoDetails,
|
||||||
|
branch_name: BranchName,
|
||||||
|
to_commit: GitRef,
|
||||||
|
force: push::Force,
|
||||||
|
) -> Result<(), push::Error>;
|
||||||
|
}
|
||||||
|
impl std::ops::Deref for OpenRepository {
|
||||||
|
type Target = dyn OpenRepositoryLike;
|
||||||
|
|
||||||
|
fn deref(&self) -> &Self::Target {
|
||||||
|
match self {
|
||||||
|
Self::Real(real) => real,
|
||||||
|
Self::Mock => todo!(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, derive_more::Constructor)]
|
||||||
|
pub struct RealOpenRepository(Arc<Mutex<gix::Repository>>);
|
||||||
|
impl OpenRepositoryLike for RealOpenRepository {
|
||||||
|
fn find_default_remote(&self, direction: Direction) -> Option<GitRemote> {
|
||||||
|
let Ok(repository) = self.0.lock() else {
|
||||||
|
return None;
|
||||||
|
};
|
||||||
|
let Some(Ok(remote)) = repository.find_default_remote(direction.into()) else {
|
||||||
|
return None;
|
||||||
|
};
|
||||||
|
let url = remote.url(direction.into())?;
|
||||||
|
let host = url.host()?;
|
||||||
|
let path = url.path.to_string();
|
||||||
|
let path = path.strip_prefix('/').map_or(path.as_str(), |path| path);
|
||||||
|
let path = path.strip_suffix(".git").map_or(path, |path| path);
|
||||||
|
Some(GitRemote::new(
|
||||||
|
Hostname::new(host),
|
||||||
|
RepoPath::new(path.to_string()),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fetch(&self) -> Result<(), fetch::Error> {
|
||||||
|
let Ok(repository) = self.0.lock() else {
|
||||||
|
return Err(fetch::Error::Lock);
|
||||||
|
};
|
||||||
|
let Some(Ok(remote)) = repository.find_default_remote(Direction::Fetch.into()) else {
|
||||||
|
return Err(fetch::Error::NoFetchRemoteFound);
|
||||||
|
};
|
||||||
|
remote
|
||||||
|
.connect(gix::remote::Direction::Fetch)
|
||||||
|
.map_err(|e| fetch::Error::Connect(e.to_string()))?
|
||||||
|
.prepare_fetch(gix::progress::Discard, Default::default())
|
||||||
|
.map_err(|e| fetch::Error::Fetch(e.to_string()))?
|
||||||
|
.receive(gix::progress::Discard, &Default::default())
|
||||||
|
.map_err(|e| fetch::Error::Fetch(e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: (#72) reimplement using `gix`
|
||||||
|
fn push(
|
||||||
|
&self,
|
||||||
|
repo_details: &RepoDetails,
|
||||||
|
branch_name: BranchName,
|
||||||
|
to_commit: GitRef,
|
||||||
|
force: push::Force,
|
||||||
|
) -> Result<(), push::Error> {
|
||||||
|
let origin = repo_details.origin();
|
||||||
|
let force = match force {
|
||||||
|
push::Force::No => "".to_string(),
|
||||||
|
push::Force::From(old_ref) => format!("--force-with-lease={branch_name}:{old_ref}"),
|
||||||
|
};
|
||||||
|
// INFO: never log the command as it contains the API token within the 'origin'
|
||||||
|
use secrecy::ExposeSecret;
|
||||||
|
let command: secrecy::Secret<String> = format!(
|
||||||
|
"/usr/bin/git push {} {to_commit}:{branch_name} {force}",
|
||||||
|
origin.expose_secret()
|
||||||
|
)
|
||||||
|
.into();
|
||||||
|
|
||||||
|
let git_dir = self
|
||||||
|
.0
|
||||||
|
.lock()
|
||||||
|
.map_err(|_| push::Error::Lock)
|
||||||
|
.map(|r| r.git_dir().to_path_buf())?;
|
||||||
|
|
||||||
|
let ctx = gix::diff::command::Context {
|
||||||
|
git_dir: Some(git_dir.clone()),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
match gix::command::prepare(command.expose_secret())
|
||||||
|
.with_context(ctx)
|
||||||
|
.with_shell_allow_argument_splitting()
|
||||||
|
.stdout(std::process::Stdio::null())
|
||||||
|
.stderr(std::process::Stdio::null())
|
||||||
|
.spawn()
|
||||||
|
{
|
||||||
|
Ok(mut child) => match child.wait() {
|
||||||
|
Ok(_) => {
|
||||||
|
info!("Branch updated");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
warn!(?err, ?git_dir, "Failed (wait)");
|
||||||
|
Err(push::Error::Push)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Err(err) => {
|
||||||
|
warn!(?err, ?git_dir, "Failed (spawn)");
|
||||||
|
Err(push::Error::Push)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
23
crates/git/src/repository/real.rs
Normal file
23
crates/git/src/repository/real.rs
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
use super::*;
|
||||||
|
pub struct RealRepository;
|
||||||
|
impl RepositoryLike for RealRepository {
|
||||||
|
fn open(&self, gitdir: &GitDir) -> Result<open::OpenRepository, Error> {
|
||||||
|
Ok(open::OpenRepository::Real(open::RealOpenRepository::new(
|
||||||
|
Arc::new(Mutex::new(
|
||||||
|
gix::ThreadSafeRepository::open(gitdir.to_path_buf())?.to_thread_local(),
|
||||||
|
)),
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn git_clone(&self, repo_details: &RepoDetails) -> Result<open::OpenRepository, Error> {
|
||||||
|
use secrecy::ExposeSecret;
|
||||||
|
let origin = repo_details.origin();
|
||||||
|
let (repository, _outcome) =
|
||||||
|
gix::prepare_clone_bare(origin.expose_secret().as_str(), repo_details.gitdir.deref())?
|
||||||
|
.fetch_only(gix::progress::Discard, &AtomicBool::new(false))?;
|
||||||
|
|
||||||
|
Ok(open::OpenRepository::Real(open::RealOpenRepository::new(
|
||||||
|
Arc::new(Mutex::new(repository)),
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in a new issue