~radicle-link/dev

radicle-link: Storage Hooks v1 PROPOSED

This patch series covers the implementation of RFC 703. It introduces
a new crate link-hooks that is re-exported from
`librad::git::hooks`.

For further details on the implementation, see the commit messages.

Published-as: https://github.com/FintanH/radicle-link/tree/patches/storage-hooks%2Fv1


Fintan Halpenny (5):
  librad::paths: add hooks directory
  link-hooks
  link-hooks: property tests
  link-hooks: smoke test running hooks
  librad: wire up link-hooks

 Cargo.toml                            |   1 +
 bins/Cargo.lock                       | 458 ++++++++++++++------------
 librad/Cargo.toml                     |   3 +
 librad/src/git.rs                     |   1 +
 librad/src/git/hooks.rs               |  66 ++++
 librad/src/paths.rs                   |  18 +-
 link-hooks/Cargo.toml                 |  40 +++
 link-hooks/src/data.rs                | 114 +++++++
 link-hooks/src/hook.rs                | 373 +++++++++++++++++++++
 link-hooks/src/hook/config.rs         |  28 ++
 link-hooks/src/lib.rs                 |  59 ++++
 link-hooks/src/sealed.rs              |   4 +
 link-hooks/src/track.rs               | 135 ++++++++
 link-hooks/t/Cargo.toml               |  57 ++++
 link-hooks/t/src/gen.rs               |   5 +
 link-hooks/t/src/gen/data.rs          |  15 +
 link-hooks/t/src/gen/track.rs         |  29 ++
 link-hooks/t/src/integration.rs       |   4 +
 link-hooks/t/src/integration/smoke.rs | 156 +++++++++
 link-hooks/t/src/lib.rs               |  10 +
 link-hooks/t/src/properties.rs        |  95 ++++++
 test/Cargo.toml                       |   4 +
 test/hooks/Cargo.toml                 |   6 +
 test/hooks/echo-data/Cargo.toml       |  10 +
 test/hooks/echo-data/src/main.rs      |  32 ++
 test/hooks/echo-forever/Cargo.toml    |  10 +
 test/hooks/echo-forever/src/main.rs   |   6 +
 test/hooks/echo-track/Cargo.toml      |  10 +
 test/hooks/echo-track/src/main.rs     |  32 ++
 29 files changed, 1565 insertions(+), 216 deletions(-)
 create mode 100644 librad/src/git/hooks.rs
 create mode 100644 link-hooks/Cargo.toml
 create mode 100644 link-hooks/src/data.rs
 create mode 100644 link-hooks/src/hook.rs
 create mode 100644 link-hooks/src/hook/config.rs
 create mode 100644 link-hooks/src/lib.rs
 create mode 100644 link-hooks/src/sealed.rs
 create mode 100644 link-hooks/src/track.rs
 create mode 100644 link-hooks/t/Cargo.toml
 create mode 100644 link-hooks/t/src/gen.rs
 create mode 100644 link-hooks/t/src/gen/data.rs
 create mode 100644 link-hooks/t/src/gen/track.rs
 create mode 100644 link-hooks/t/src/integration.rs
 create mode 100644 link-hooks/t/src/integration/smoke.rs
 create mode 100644 link-hooks/t/src/lib.rs
 create mode 100644 link-hooks/t/src/properties.rs
 create mode 100644 test/hooks/Cargo.toml
 create mode 100644 test/hooks/echo-data/Cargo.toml
 create mode 100644 test/hooks/echo-data/src/main.rs
 create mode 100644 test/hooks/echo-forever/Cargo.toml
 create mode 100644 test/hooks/echo-forever/src/main.rs
 create mode 100644 test/hooks/echo-track/Cargo.toml
 create mode 100644 test/hooks/echo-track/src/main.rs

-- 
2.31.1
#771712 nixos-latest.yml failed
radicle-link/patches/nixos-latest.yml: FAILED in 25m26s

[Storage Hooks][0] from [Fintan Halpenny][1]

[0]: https://lists.sr.ht/~radicle-link/dev/patches/32680
[1]: mailto:fintan.halpenny@gmail.com

✗ #771712 FAILED radicle-link/patches/nixos-latest.yml https://builds.sr.ht/~radicle-link/job/771712
LGTM
Export patchset (mbox)
How do I use this?

Copy & paste the following snippet into your terminal to import this patchset into git:

curl -s https://lists.sr.ht/~radicle-link/dev/patches/32680/mbox | git am -3
Learn more about email & git

[PATCH radicle-link v1 1/5] librad::paths: add hooks directory Export this patch

As per RFC 703[0], add a `hooks` directory to `Paths`.

[0]: https://github.com/radicle-dev/radicle-link/blob/master/docs/rfc/0703-storage-hooks.adoc

Signed-off-by: Fintan Halpenny <fintan.halpenny@gmail.com>
---
 librad/src/paths.rs | 18 ++++++++++++++++--
 1 file changed, 16 insertions(+), 2 deletions(-)

diff --git a/librad/src/paths.rs b/librad/src/paths.rs
index 7fb8440d..89604282 100644
--- a/librad/src/paths.rs
+++ b/librad/src/paths.rs
@@ -26,6 +26,7 @@ pub struct Paths {
    cob_cache_dir: PathBuf,
    socket_dir: PathBuf,
    seeds_file: PathBuf,
    hooks_dir: PathBuf,
}

impl Paths {
@@ -38,14 +39,17 @@ impl Paths {
        let config_dir = proj.config_dir().join(profile_id);
        let data_dir = proj.data_dir().join(profile_id);
        let cache_dir = proj.cache_dir().join(profile_id);
        let git_dir = data_dir.join("git");
        let hooks_dir = git_dir.join("hooks");

        Self {
            keys_dir: config_dir.join("keys"),
            git_dir: data_dir.join("git"),
            git_dir,
            git_includes_dir: config_dir.join("git-includes"),
            cob_cache_dir: cache_dir.join("cob-cache"),
            socket_dir: socket_dir()?,
            seeds_file: config_dir.join("seeds"),
            hooks_dir,
        }
        .init()
    }
@@ -53,13 +57,17 @@ impl Paths {
    /// All paths are contained in the given directory.
    pub fn from_root(root: impl AsRef<Path>) -> Result<Self, io::Error> {
        let root = root.as_ref();
        let git_dir = root.join("git");
        let hooks_dir = git_dir.join("hooks");

        Self {
            keys_dir: root.join("keys"),
            git_dir: root.join("git"),
            git_dir,
            git_includes_dir: root.join("git-includes"),
            cob_cache_dir: root.join("cob-cache"),
            socket_dir: socket_dir()?,
            seeds_file: root.join("seeds"),
            hooks_dir,
        }
        .init()
    }
@@ -80,6 +88,10 @@ impl Paths {
        &self.cob_cache_dir
    }

    pub fn hooks_dir(&self) -> &Path {
        &self.hooks_dir
    }

    pub fn all_dirs(&self) -> impl Iterator<Item = &Path> {
        // Nb. this pattern match is here to keep the map consistent with the
        // struct fields
@@ -88,6 +100,7 @@ impl Paths {
            git_dir,
            git_includes_dir,
            cob_cache_dir,
            hooks_dir,
            socket_dir: _,
            seeds_file: _,
        } = self;
@@ -97,6 +110,7 @@ impl Paths {
            git_dir.as_path(),
            git_includes_dir.as_path(),
            cob_cache_dir.as_path(),
            hooks_dir.as_path(),
        ]
        .into_iter()
    }
-- 
2.31.1

[PATCH radicle-link v2 1/6] librad::paths: add hooks directory Export this patch

As per RFC 703[0], add a `hooks` directory to `Paths`.

[0]: https://github.com/radicle-dev/radicle-link/blob/master/docs/rfc/0703-storage-hooks.adoc

Signed-off-by: Fintan Halpenny <fintan.halpenny@gmail.com>
---
 librad/src/paths.rs | 10 ++++++++++
 1 file changed, 10 insertions(+)

diff --git a/librad/src/paths.rs b/librad/src/paths.rs
index 7fb8440d..a3d5b31f 100644
--- a/librad/src/paths.rs
+++ b/librad/src/paths.rs
@@ -26,6 +26,7 @@ pub struct Paths {
    cob_cache_dir: PathBuf,
    socket_dir: PathBuf,
    seeds_file: PathBuf,
    hooks_dir: PathBuf,
}

impl Paths {
@@ -46,6 +47,7 @@ impl Paths {
            cob_cache_dir: cache_dir.join("cob-cache"),
            socket_dir: socket_dir()?,
            seeds_file: config_dir.join("seeds"),
            hooks_dir: data_dir.join("hooks"),
        }
        .init()
    }
@@ -53,6 +55,7 @@ impl Paths {
    /// All paths are contained in the given directory.
    pub fn from_root(root: impl AsRef<Path>) -> Result<Self, io::Error> {
        let root = root.as_ref();

        Self {
            keys_dir: root.join("keys"),
            git_dir: root.join("git"),
@@ -60,6 +63,7 @@ impl Paths {
            cob_cache_dir: root.join("cob-cache"),
            socket_dir: socket_dir()?,
            seeds_file: root.join("seeds"),
            hooks_dir: root.join("hooks"),
        }
        .init()
    }
@@ -80,6 +84,10 @@ impl Paths {
        &self.cob_cache_dir
    }

    pub fn hooks_dir(&self) -> &Path {
        &self.hooks_dir
    }

    pub fn all_dirs(&self) -> impl Iterator<Item = &Path> {
        // Nb. this pattern match is here to keep the map consistent with the
        // struct fields
@@ -88,6 +96,7 @@ impl Paths {
            git_dir,
            git_includes_dir,
            cob_cache_dir,
            hooks_dir,
            socket_dir: _,
            seeds_file: _,
        } = self;
@@ -97,6 +106,7 @@ impl Paths {
            git_dir.as_path(),
            git_includes_dir.as_path(),
            cob_cache_dir.as_path(),
            hooks_dir.as_path(),
        ]
        .into_iter()
    }
-- 
2.31.1

[PATCH radicle-link v1 2/5] link-hooks Export this patch

The implementation of RFC 703[0].

This crate is broken down into the following components:
* data and track -- for representing the URN and tracking changes that
get communicated to the individual hooks. They are kept generic over
their revision type and can be rendered to and parsed from
strings.
* hook -- the types and traits for running hooks and communicating to
them. A single `Hook` is represented by its path to the executable and
the subprocess it is running. This subprocess must implement the
`Handle` trait which knows how to `spawn` the process, `write` to the
process, and `wait_or_kill` the process. A set of `Hooks` is run using
an unordered manner, getting passed `Notification`s via a stream and
then terminated once the stream is exhausted.

[0]: https://github.com/radicle-dev/radicle-link/blob/master/docs/rfc/0703-storage-hooks.adoc

Signed-off-by: Fintan Halpenny <fintan.halpenny@gmail.com>
---
 Cargo.toml                    |   1 +
 link-hooks/Cargo.toml         |  40 ++++
 link-hooks/src/data.rs        | 114 +++++++++++
 link-hooks/src/hook.rs        | 373 ++++++++++++++++++++++++++++++++++
 link-hooks/src/hook/config.rs |  28 +++
 link-hooks/src/lib.rs         |  59 ++++++
 link-hooks/src/sealed.rs      |   4 +
 link-hooks/src/track.rs       | 135 ++++++++++++
 8 files changed, 754 insertions(+)
 create mode 100644 link-hooks/Cargo.toml
 create mode 100644 link-hooks/src/data.rs
 create mode 100644 link-hooks/src/hook.rs
 create mode 100644 link-hooks/src/hook/config.rs
 create mode 100644 link-hooks/src/lib.rs
 create mode 100644 link-hooks/src/sealed.rs
 create mode 100644 link-hooks/src/track.rs

diff --git a/Cargo.toml b/Cargo.toml
index d56138c3..29c8ae0d 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -13,6 +13,7 @@ members = [
  "link-canonical-derive",
  "link-crypto",
  "link-git",
  "link-hooks",
  "link-identities",
  "link-replication",
  "link-tracking",
diff --git a/link-hooks/Cargo.toml b/link-hooks/Cargo.toml
new file mode 100644
index 00000000..2afd123b
--- /dev/null
+++ b/link-hooks/Cargo.toml
@@ -0,0 +1,40 @@
[package]
name = "link-hooks"
version = "0.1.0"
authors = ["Fintan Halpenny <fintan.halpenny@gmail.com>"]
edition = "2021"
license = "GPL-3.0-or-later"

[lib]
doctest = false
test = false

[features]
git = ["git2", "radicle-git-ext"]

[dependencies]
async-trait = "0.1"
futures = "0.3"
multihash = "0.11"
thiserror = "1"
tracing = "0.1"

[dependencies.git2]
version = "0.13.24"
default-features = false
features = ["vendored-libgit2"]
optional = true

[dependencies.radicle-git-ext]
path = "../git-ext"
optional = true

[dependencies.link-crypto]
path = "../link-crypto"

[dependencies.link-identities]
path = "../link-identities"

[dependencies.tokio]
version = "1.18"
features = ["io-util", "process", "sync", "time"]
diff --git a/link-hooks/src/data.rs b/link-hooks/src/data.rs
new file mode 100644
index 00000000..82511680
--- /dev/null
+++ b/link-hooks/src/data.rs
@@ -0,0 +1,114 @@
// Copyright © 2022 The Radicle Link Contributors
// SPDX-License-Identifier: GPL-3.0-or-later

use std::{fmt, str::FromStr};

use link_identities::urn::{HasProtocol, Urn};
use multihash::Multihash;

use super::{sealed, Display, IsZero, Updated};

#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Data<R> {
    pub urn: Urn<R>,
    pub old: R,
    pub new: R,
}

impl<R> Data<R>
where
    R: IsZero + PartialEq,
{
    pub fn updated(&self) -> Updated {
        match (self.old.is_zero(), self.new.is_zero()) {
            (true, true) => Updated::Zero,
            (true, false) => Updated::Created,
            (false, true) => Updated::Deleted,
            (false, false) if self.old != self.new => Updated::Changed,
            _ => Updated::NoChange,
        }
    }
}

impl<R> fmt::Display for Data<R>
where
    R: HasProtocol + fmt::Display,
    for<'a> &'a R: Into<Multihash>,
{
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "{} ", self.urn)?;

        writeln!(f, "{} {}", self.old, self.new)
    }
}

impl<R> sealed::Sealed for Data<R> {}
impl<R> Display for Data<R>
where
    R: HasProtocol + fmt::Display,
    for<'a> &'a R: Into<Multihash>,
{
    fn display(&self) -> String {
        self.to_string()
    }
}

impl<R, E> FromStr for Data<R>
where
    R: HasProtocol + TryFrom<Multihash, Error = E> + FromStr,
    R::Err: std::error::Error + Send + Sync + 'static,
    E: std::error::Error + Send + Sync + 'static,
{
    type Err = error::Parse<E>;

    fn from_str(s: &str) -> Result<Self, Self::Err> {
        let mut components = s.split(' ');

        let urn = match components.next() {
            Some(urn) => urn.parse::<Urn<R>>()?,
            None => return Err(error::Parse::Missing("rad:git:<identitifier>[/<path>]")),
        };

        let old = match components.next() {
            Some(old) => old
                .parse::<R>()
                .map_err(|err| error::Parse::Revision(Box::new(err)))?,
            None => return Err(error::Parse::Missing("<old>")),
        };

        let new = match components.next() {
            Some(new) => match new.strip_suffix('\n') {
                None => return Err(error::Parse::Newline(new.to_string())),
                Some(new) => new
                    .parse::<R>()
                    .map_err(|err| error::Parse::Revision(Box::new(err)))?,
            },
            None => return Err(error::Parse::Missing("<new> LF")),
        };

        if let Some(extra) = components.next() {
            return Err(error::Parse::Extra(extra.to_string()));
        }

        Ok(Self { urn, old, new })
    }
}

pub mod error {
    use link_identities::urn;
    use thiserror::Error;

    #[derive(Debug, Error)]
    pub enum Parse<E: std::error::Error + Send + Sync + 'static> {
        #[error("found extra data {0}")]
        Extra(String),
        #[error("missing component {0}")]
        Missing(&'static str),
        #[error("expected newline, but found {0}")]
        Newline(String),
        #[error(transparent)]
        Revision(Box<dyn std::error::Error + Send + Sync + 'static>),
        #[error(transparent)]
        Urn(#[from] urn::error::FromStr<E>),
    }
}
diff --git a/link-hooks/src/hook.rs b/link-hooks/src/hook.rs
new file mode 100644
index 00000000..ef0336f1
--- /dev/null
+++ b/link-hooks/src/hook.rs
@@ -0,0 +1,373 @@
// Copyright © 2022 The Radicle Link Contributors
// SPDX-License-Identifier: GPL-3.0-or-later

use std::{collections::HashMap, ffi::OsStr, fmt, path::PathBuf, str::FromStr, time::Duration};

use futures::{future::BoxFuture, stream::FuturesUnordered, FutureExt, Stream, StreamExt as _};
use multihash::Multihash;
use tokio::sync::mpsc;

use link_identities::urn::HasProtocol;

use super::{Data, Display, Track};

pub mod config;
pub use config::Config;

/// End of transimission character.
pub const EOT: u8 = 0x04;

/// A notification sent by the notifying process to the set of hook processes.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Notification<R> {
    Track(Track<R>),
    Data(Data<R>),
}

impl<R> fmt::Display for Notification<R>
where
    R: HasProtocol + fmt::Display,
    for<'a> &'a R: Into<Multihash>,
{
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::Track(track) => write!(f, "{}", track),
            Self::Data(data) => write!(f, "{}", data),
        }
    }
}

impl<R> From<Track<R>> for Notification<R> {
    fn from(t: Track<R>) -> Self {
        Self::Track(t)
    }
}

impl<R> From<Data<R>> for Notification<R> {
    fn from(t: Data<R>) -> Self {
        Self::Data(t)
    }
}

/// Executor for a set of [`Hook`]s that will receive and process
/// [`Notification`]s via a channel.
pub struct Hooks<P: Handle> {
    data_hooks: Vec<Hook<P>>,
    track_hooks: Vec<Hook<P>>,
    config: Config,
}

impl<P: Handle + Send + Sync + 'static> Hooks<P> {
    /// Construct the `Hooks` runner.
    ///
    /// To create a [`Hook`], use the [`Handle::spawn`] constructor, where the
    /// child process of the `Hook` must also implement [`Handle`].
    ///
    /// To start the hooks routine and process [`Notification`]s, use
    /// [`Hooks::run`].
    pub fn new(config: Config, data_hooks: Vec<Hook<P>>, track_hooks: Vec<Hook<P>>) -> Self {
        Self {
            data_hooks,
            track_hooks,
            config,
        }
    }

    /// The `incoming` [`Notification`]s are sent to each respective hook,
    /// depending on the notification variant, until the stream is exhausted.
    ///
    /// Once the stream is complete, the end-of-transmission character is sent
    /// to every hook to signal that they should stop. The hook is given a
    /// grace period to stop and exit, otherwise it will be terminated after the
    /// timeout given in the [`Config`].
    pub async fn run<S, R>(self, mut incoming: S)
    where
        R: Clone + HasProtocol + std::fmt::Display + Send + Sync + 'static,
        for<'b> &'b R: Into<Multihash>,
        S: Stream<Item = Notification<R>> + Unpin,
    {
        use senders::{Event, Senders};

        let mut routines = FuturesUnordered::new();
        let mut data_senders: Senders<Data<R>> = Senders::new(Event::Data);
        let mut track_senders: Senders<Track<R>> = Senders::new(Event::Track);

        for hook in self.data_hooks {
            let path = hook.path.clone();
            tracing::debug!(hook = %path.display(), "starting data hook");
            let (sender, routine) = hook.start(self.config.hook);
            data_senders.insert(path, sender);
            routines.push(routine);
        }
        for hook in self.track_hooks {
            let path = hook.path.clone();
            tracing::debug!(hook = %path.display(), "starting track hook");
            let (sender, routine) = hook.start(self.config.hook);
            track_senders.insert(path, sender);
            routines.push(routine);
        }
        loop {
            futures::select! {
                failed_hook_path = routines.next().fuse() => {
                    if let Some(failed_hook_path) = failed_hook_path {
                        tracing::warn!(hook = %failed_hook_path.display(), "hook failed, removing from hooks set");
                        data_senders.remove(&failed_hook_path);
                        track_senders.remove(&failed_hook_path);
                    } else {
                        tracing::error!("all hook routines have stopped");
                        break;
                    }
                }
                n = incoming.next().fuse() => {
                    match n {
                        Some(Notification::Data(d)) => {
                            tracing::trace!(data = %d, "received data notification");
                            data_senders.send(d)
                        },
                        Some(Notification::Track(t)) => {
                            tracing::trace!(track = %t, "received track notification");
                            track_senders.send(t)
                        },
                        None => {
                            tracing::trace!("finished notifications stream");
                            break
                        },
                    }
                },
            }
        }

        // Send EOTs to all senders
        data_senders.eot().await;
        track_senders.eot().await;

        // Wait for routines to complete
        for routine in routines {
            let path = routine.await;
            tracing::info!(hook = %path.display(), "hook finished");
        }
    }
}

/// A communication medium for a hook process.
///
/// # Cancel Safety
///
/// Since the cancel safety is based on the implementing data type of `Handle`,
/// it should be assumed that the methods are *not* cancel safe.
#[async_trait]
pub trait Handle: Sized {
    type SpawnError: std::error::Error + Send + Sync + 'static;
    type WriteError: std::error::Error + Send + Sync + 'static;
    type DieError: std::error::Error + Send + Sync + 'static;

    /// Spawn a new hook process where `path` points to the hook executable. The
    /// `args` should typically be `None::<String>`, but can be used for testing
    /// purposes.
    async fn spawn<I, S>(path: PathBuf, args: I) -> Result<Self, Self::SpawnError>
    where
        I: IntoIterator<Item = S> + Send,
        S: AsRef<OsStr>;

    /// Write data to the hook process.
    async fn write(&mut self, bs: &[u8]) -> Result<(), Self::WriteError>;

    /// Wait for the hook process to finish, or kill after `duration`.
    async fn wait_or_kill(&mut self, duration: Duration) -> Result<(), Self::DieError>;
}

/// A spawned hook process.
pub struct Hook<P: Handle> {
    path: PathBuf,
    child: P,
}

pub enum HookMessage<T> {
    /// End of transmission message.
    EOT,
    /// The payload to be sent to a hook, usually [`Data`] or [`Track`].
    Payload(T),
}

impl<T> From<T> for HookMessage<T> {
    fn from(t: T) -> Self {
        Self::Payload(t)
    }
}

impl<T: FromStr> FromStr for HookMessage<T> {
    type Err = T::Err;

    fn from_str(s: &str) -> Result<Self, Self::Err> {
        if s == String::from_utf8(vec![EOT]).expect("BUG: EOT is valid utf-8") {
            Ok(Self::EOT)
        } else {
            s.parse().map(Self::Payload)
        }
    }
}

impl<P: Handle + Send + Sync + 'static> Hook<P> {
    pub fn new(path: PathBuf, child: P) -> Self {
        Self { path, child }
    }

    #[tracing::instrument(skip(self), fields(hook = ?self.path))]
    pub fn start<'a, D>(
        mut self,
        config: config::Hook,
    ) -> (mpsc::Sender<HookMessage<D>>, BoxFuture<'a, PathBuf>)
    where
        D: Display + Send + Sync + 'static,
    {
        let (sx, mut rx) = mpsc::channel::<HookMessage<D>>(config.buffer);
        let routine = async move {
            tracing::trace!("waiting for notification");
            while let Some(msg) = rx.recv().await {
                match msg {
                    HookMessage::EOT => {
                        if let Err(err) = self.write(&[EOT]).await {
                            tracing::warn!(err = %err, "failed to write EOT to hook");
                        }
                        if let Err(err) = self.wait_or_kill(config.timeout).await {
                            tracing::warn!(err = %err, "failed to terminate hook");
                        }
                        return self.path;
                    },
                    HookMessage::Payload(msg) => {
                        if let Err(err) = self.write(msg.display().as_bytes()).await {
                            tracing::warn!(err = %err, "failed to write to hook");
                            return self.path;
                        }
                    },
                }
            }
            self.path
        }
        .boxed();
        (sx, routine)
    }
}

#[async_trait]
impl<P> Handle for Hook<P>
where
    P: Handle + Send + Sync + 'static,
{
    type WriteError = P::WriteError;
    type SpawnError = P::SpawnError;
    type DieError = P::DieError;

    async fn spawn<I, S>(path: PathBuf, args: I) -> Result<Self, Self::SpawnError>
    where
        I: IntoIterator<Item = S> + Send,
        S: AsRef<OsStr>,
    {
        Ok(Self {
            path: path.clone(),
            child: P::spawn(path, args).await?,
        })
    }

    async fn write(&mut self, bs: &[u8]) -> Result<(), Self::WriteError> {
        self.child.write(bs).await
    }

    async fn wait_or_kill(&mut self, duration: Duration) -> Result<(), Self::DieError> {
        self.child.wait_or_kill(duration).await
    }
}

pub(super) mod senders {
    use super::*;

    #[derive(Debug)]
    pub enum Event {
        Track,
        Data,
    }

    pub struct Senders<P> {
        senders: HashMap<PathBuf, mpsc::Sender<HookMessage<P>>>,
        kind: Event,
    }

    impl<P> Senders<P> {
        pub fn new(kind: Event) -> Self {
            Self {
                senders: HashMap::new(),
                kind,
            }
        }

        pub fn insert(&mut self, path: PathBuf, sender: mpsc::Sender<HookMessage<P>>) {
            self.senders.insert(path, sender);
        }

        pub fn remove(&mut self, path: &PathBuf) {
            self.senders.remove(path);
        }

        pub fn send(&self, p: P)
        where
            P: Clone,
        {
            for (path, sender) in self.senders.iter() {
                if sender.try_send(p.clone().into()).is_err() {
                    tracing::warn!(hook=%path.display(), kind=?self.kind, "dropping message for hook which is running too slowly");
                }
            }
        }

        pub async fn eot(&self) {
            for sender in self.senders.values() {
                sender.send(HookMessage::EOT).await.ok();
            }
        }
    }
}

mod tokio_impl {
    use std::{ffi::OsStr, io, path::PathBuf, process::Stdio, time::Duration};
    use tokio::{
        io::AsyncWriteExt,
        process::{Child, Command},
    };

    use super::Handle;

    #[async_trait]
    impl Handle for Child {
        type WriteError = io::Error;
        type SpawnError = io::Error;
        type DieError = io::Error;

        async fn spawn<I, S>(path: PathBuf, args: I) -> Result<Self, Self::SpawnError>
        where
            I: IntoIterator<Item = S> + Send,
            S: AsRef<OsStr>,
        {
            let child = Command::new(path)
                .stdin(Stdio::piped())
                .args(args)
                .spawn()?;
            Ok(child)
        }

        async fn write(&mut self, bs: &[u8]) -> Result<(), Self::WriteError> {
            self.stdin
                .as_mut()
                .expect("BUG: stdin was not set up for subprocess")
                .write_all(bs)
                .await
        }

        async fn wait_or_kill(&mut self, duration: Duration) -> Result<(), Self::DieError> {
            if tokio::time::timeout(duration, self.wait()).await.is_err() {
                self.kill().await
            } else {
                Ok(())
            }
        }
    }
}
diff --git a/link-hooks/src/hook/config.rs b/link-hooks/src/hook/config.rs
new file mode 100644
index 00000000..520d9f40
--- /dev/null
+++ b/link-hooks/src/hook/config.rs
@@ -0,0 +1,28 @@
// Copyright © 2022 The Radicle Link Contributors
// SPDX-License-Identifier: GPL-3.0-or-later

use std::time::Duration;

#[derive(Clone, Copy, Debug, Default)]
pub struct Config {
    /// Configuration for the set of [`super::Hooks`]
    pub hook: Hook,
}

#[derive(Clone, Copy, Debug)]
pub struct Hook {
    /// The buffer size for the hook's internal channel.
    pub buffer: usize,
    /// The duration to wait for a hook to complete after the
    /// end-of-transmission message before it is forcefully killed.
    pub timeout: Duration,
}

impl Default for Hook {
    fn default() -> Self {
        Self {
            buffer: 10,
            timeout: Duration::from_secs(2),
        }
    }
}
diff --git a/link-hooks/src/lib.rs b/link-hooks/src/lib.rs
new file mode 100644
index 00000000..6edca5b7
--- /dev/null
+++ b/link-hooks/src/lib.rs
@@ -0,0 +1,59 @@
// Copyright © 2022 The Radicle Link Contributors
// SPDX-License-Identifier: GPL-3.0-or-later

#[macro_use]
extern crate async_trait;

pub mod data;
pub use data::Data;

pub mod track;
pub use track::Track;

pub mod hook;
pub use hook::{Hooks, Notification};

mod sealed;

pub trait Display: sealed::Sealed {
    fn display(&self) -> String;
}

pub trait IsZero {
    fn is_zero(&self) -> bool;
}

/// The updated summary of the `old` and `new` revisions.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum Updated {
    /// Both `old` and `new` are the zero revision.
    Zero,
    /// `old` is the zero revision and `new` is a non-zero revision.
    Created,
    /// `old` is a non-zero revision and `new` is the zero revision.
    Deleted,
    /// Both `old` and `new` are non-zero revisions.
    Changed,
    /// Both `old` and `new` are the same non-zero revision.
    NoChange,
}

#[cfg(feature = "git")]
mod git {
    use git2::Oid;
    use radicle_git_ext as ext;

    use super::IsZero;

    impl IsZero for Oid {
        fn is_zero(&self) -> bool {
            self == &Oid::zero()
        }
    }

    impl IsZero for ext::Oid {
        fn is_zero(&self) -> bool {
            git2::Oid::from(*self).is_zero()
        }
    }
}
diff --git a/link-hooks/src/sealed.rs b/link-hooks/src/sealed.rs
new file mode 100644
index 00000000..d5ae7675
--- /dev/null
+++ b/link-hooks/src/sealed.rs
@@ -0,0 +1,4 @@
// Copyright © 2022 The Radicle Link Contributors
// SPDX-License-Identifier: GPL-3.0-or-later

pub trait Sealed {}
diff --git a/link-hooks/src/track.rs b/link-hooks/src/track.rs
new file mode 100644
index 00000000..e4e2e795
--- /dev/null
+++ b/link-hooks/src/track.rs
@@ -0,0 +1,135 @@
// Copyright © 2022 The Radicle Link Contributors
// SPDX-License-Identifier: GPL-3.0-or-later

use std::{fmt, str::FromStr};

use link_crypto::PeerId;
use link_identities::urn::{HasProtocol, Urn};
use multihash::Multihash;

use super::{sealed, Display, IsZero, Updated};

#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Track<R> {
    pub urn: Urn<R>,
    pub peer: Option<PeerId>,
    pub old: R,
    pub new: R,
}

impl<R> Track<R>
where
    R: IsZero + PartialEq,
{
    pub fn updated(&self) -> Updated {
        match (self.old.is_zero(), self.new.is_zero()) {
            (true, true) => Updated::Zero,
            (true, false) => Updated::Created,
            (false, true) => Updated::Deleted,
            (false, false) if self.old != self.new => Updated::Changed,
            _ => Updated::NoChange,
        }
    }
}

impl<R> fmt::Display for Track<R>
where
    R: HasProtocol + fmt::Display,
    for<'a> &'a R: Into<Multihash>,
{
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "{} ", self.urn)?;

        match self.peer {
            None => write!(f, "default "),
            Some(peer) => write!(f, "{} ", peer),
        }?;

        writeln!(f, "{} {}", self.old, self.new)
    }
}

impl<R> sealed::Sealed for Track<R> {}
impl<R> Display for Track<R>
where
    R: HasProtocol + fmt::Display,
    for<'a> &'a R: Into<Multihash>,
{
    fn display(&self) -> String {
        self.to_string()
    }
}

impl<R, E> FromStr for Track<R>
where
    R: HasProtocol + TryFrom<Multihash, Error = E> + FromStr,
    R::Err: std::error::Error + Send + Sync + 'static,
    E: std::error::Error + Send + Sync + 'static,
{
    type Err = error::Parse<E>;

    fn from_str(s: &str) -> Result<Self, Self::Err> {
        let mut components = s.split(' ');

        let urn = match components.next() {
            Some(urn) => urn.parse::<Urn<R>>()?,
            None => return Err(error::Parse::Missing("rad:git:<identitifier>[/<path>]")),
        };

        let peer = match components.next() {
            Some("default") => None,
            Some(peer) => peer.parse().map(Some)?,
            None => return Err(error::Parse::Missing("<peer id>")),
        };

        let old = match components.next() {
            Some(old) => old
                .parse::<R>()
                .map_err(|err| error::Parse::Revision(Box::new(err)))?,
            None => return Err(error::Parse::Missing("<old>")),
        };

        let new = match components.next() {
            Some(new) => match new.strip_suffix('\n') {
                None => return Err(error::Parse::Newline(new.to_string())),
                Some(new) => new
                    .parse::<R>()
                    .map_err(|err| error::Parse::Revision(Box::new(err)))?,
            },
            None => return Err(error::Parse::Missing("<new> LF")),
        };

        if let Some(extra) = components.next() {
            return Err(error::Parse::Extra(extra.to_string()));
        }

        Ok(Self {
            urn,
            peer,
            old,
            new,
        })
    }
}

pub mod error {
    use link_crypto::peer;
    use link_identities::urn;
    use thiserror::Error;

    #[derive(Debug, Error)]
    pub enum Parse<E: std::error::Error + Send + Sync + 'static> {
        #[error("found extra data {0}")]
        Extra(String),
        #[error("missing component {0}")]
        Missing(&'static str),
        #[error("expected newline, but found {0}")]
        Newline(String),
        #[error(transparent)]
        Peer(#[from] peer::conversion::Error),
        #[error(transparent)]
        Revision(Box<dyn std::error::Error + Send + Sync + 'static>),
        #[error(transparent)]
        Urn(#[from] urn::error::FromStr<E>),
    }
}
-- 
2.31.1

[PATCH radicle-link v2 2/6] link-identities: add gen_oid_with_zero Export this patch

Add a generator for OIDs to account for wanting the zeroed OID.

Signed-off-by: Fintan Halpenny <fintan.halpenny@gmail.com>
---
 link-identities/t/src/gen/urn.rs | 4 ++++
 1 file changed, 4 insertions(+)

diff --git a/link-identities/t/src/gen/urn.rs b/link-identities/t/src/gen/urn.rs
index 1fb723df..ea2142e3 100644
--- a/link-identities/t/src/gen/urn.rs
+++ b/link-identities/t/src/gen/urn.rs
@@ -14,6 +14,10 @@ pub fn gen_oid(kind: git2::ObjectType) -> impl Strategy<Value = Oid> {
        .prop_map(move |bytes| git2::Oid::hash_object(kind, &bytes).map(Oid::from).unwrap())
}

pub fn gen_oid_with_zero(kind: git2::ObjectType) -> impl Strategy<Value = Oid> {
    prop_oneof![gen_oid(kind), Just(git2::Oid::zero().into()),]
}

pub fn gen_urn() -> impl Strategy<Value = Urn<Oid>> {
    (
        gen_oid(git2::ObjectType::Tree),
-- 
2.31.1

[PATCH radicle-link v1 3/5] link-hooks: property tests Export this patch

Add property tests for roundtrip of the `Data` & `Track` data types
and their APIs.

Signed-off-by: Fintan Halpenny <fintan.halpenny@gmail.com>
---
 link-hooks/t/Cargo.toml        | 44 ++++++++++++++++
 link-hooks/t/src/gen.rs        |  5 ++
 link-hooks/t/src/gen/data.rs   | 15 ++++++
 link-hooks/t/src/gen/track.rs  | 29 +++++++++++
 link-hooks/t/src/lib.rs        |  8 +++
 link-hooks/t/src/properties.rs | 95 ++++++++++++++++++++++++++++++++++
 test/Cargo.toml                |  4 ++
 7 files changed, 200 insertions(+)
 create mode 100644 link-hooks/t/Cargo.toml
 create mode 100644 link-hooks/t/src/gen.rs
 create mode 100644 link-hooks/t/src/gen/data.rs
 create mode 100644 link-hooks/t/src/gen/track.rs
 create mode 100644 link-hooks/t/src/lib.rs
 create mode 100644 link-hooks/t/src/properties.rs

diff --git a/link-hooks/t/Cargo.toml b/link-hooks/t/Cargo.toml
new file mode 100644
index 00000000..2c3d2a14
--- /dev/null
+++ b/link-hooks/t/Cargo.toml
@@ -0,0 +1,44 @@
[package]
name = "link-hooks-test"
version = "0.1.0"
edition = "2021"
license = "GPL-3.0-or-later"

publish = false

[lib]
doctest = false
test = true
doc = false

[features]
test = []

[dependencies]
proptest = "1"

[dependencies.git2]
version = "0.13.24"
default-features = false
features = ["vendored-libgit2"]

[dependencies.link-crypto]
path = "../../link-crypto"

[dependencies.link-crypto-test]
path = "../../link-crypto/t"
features = ["test"]

[dependencies.link-hooks]
path = "../../link-hooks"
features = ["git"]

[dependencies.link-identities-test]
path = "../../link-identities/t"
features = ["test"]

[dependencies.radicle-git-ext]
path = "../../git-ext"

[dev-dependencies.test-helpers]
path = "../../test/test-helpers"
diff --git a/link-hooks/t/src/gen.rs b/link-hooks/t/src/gen.rs
new file mode 100644
index 00000000..a83564bb
--- /dev/null
+++ b/link-hooks/t/src/gen.rs
@@ -0,0 +1,5 @@
// Copyright © 2022 The Radicle Link Contributors
// SPDX-License-Identifier: GPL-3.0-or-later

pub mod data;
pub mod track;
diff --git a/link-hooks/t/src/gen/data.rs b/link-hooks/t/src/gen/data.rs
new file mode 100644
index 00000000..3397042b
--- /dev/null
+++ b/link-hooks/t/src/gen/data.rs
@@ -0,0 +1,15 @@
// Copyright © 2022 The Radicle Link Contributors
// SPDX-License-Identifier: GPL-3.0-or-later

use proptest::prelude::*;

use link_hooks::Data;
use link_identities_test::gen::urn::{gen_oid, gen_urn};
use radicle_git_ext as ext;

pub fn gen_data() -> impl Strategy<Value = Data<ext::Oid>> {
    gen_oid(git2::ObjectType::Commit).prop_flat_map(move |old| {
        gen_oid(git2::ObjectType::Commit)
            .prop_flat_map(move |new| gen_urn().prop_map(move |urn| Data { urn, old, new }))
    })
}
diff --git a/link-hooks/t/src/gen/track.rs b/link-hooks/t/src/gen/track.rs
new file mode 100644
index 00000000..49a139a0
--- /dev/null
+++ b/link-hooks/t/src/gen/track.rs
@@ -0,0 +1,29 @@
// Copyright © 2022 The Radicle Link Contributors
// SPDX-License-Identifier: GPL-3.0-or-later

use proptest::prelude::*;

use link_crypto::PeerId;
use link_crypto_test::gen::gen_peer_id;
use link_hooks::Track;
use link_identities_test::gen::urn::{gen_oid, gen_urn};
use radicle_git_ext as ext;

pub fn gen_track() -> impl Strategy<Value = Track<ext::Oid>> {
    default_or_peer().prop_flat_map(move |peer| {
        gen_oid(git2::ObjectType::Commit).prop_flat_map(move |old| {
            gen_oid(git2::ObjectType::Commit).prop_flat_map(move |new| {
                gen_urn().prop_map(move |urn| Track {
                    urn,
                    peer,
                    old,
                    new,
                })
            })
        })
    })
}

fn default_or_peer() -> impl Strategy<Value = Option<PeerId>> {
    prop_oneof![Just(None), gen_peer_id().prop_map(Some)]
}
diff --git a/link-hooks/t/src/lib.rs b/link-hooks/t/src/lib.rs
new file mode 100644
index 00000000..16452050
--- /dev/null
+++ b/link-hooks/t/src/lib.rs
@@ -0,0 +1,8 @@
// Copyright © 2022 The Radicle Link Contributors
// SPDX-License-Identifier: GPL-3.0-or-later

#[cfg(any(test, feature = "test"))]
pub mod gen;

#[cfg(test)]
mod properties;
diff --git a/link-hooks/t/src/properties.rs b/link-hooks/t/src/properties.rs
new file mode 100644
index 00000000..778a4f46
--- /dev/null
+++ b/link-hooks/t/src/properties.rs
@@ -0,0 +1,95 @@
// Copyright © 2022 The Radicle Link Contributors
// SPDX-License-Identifier: GPL-3.0-or-later

use proptest::prelude::*;

use link_hooks::{Data, IsZero, Track, Updated};
use radicle_git_ext as ext;
use test_helpers::roundtrip;

use crate::gen::{data::gen_data, track::gen_track};

proptest! {
    #[test]
    fn roundtrip_data(data in gen_data()) {
        roundtrip::str(data)
    }

    #[test]
    fn roundtrip_track(track in gen_track()) {
        roundtrip::str(track)
    }

    #[test]
    fn track_updated(track in gen_track()) {
        prop_track_created(track.clone());
        prop_track_deleted(track.clone());
        prop_track_changed(track);
    }

        #[test]
    fn data_updated(data in gen_data()) {
        prop_data_created(data.clone());
        prop_data_deleted(data.clone());
        prop_data_changed(data);
    }
}

fn prop_track_created(track: Track<ext::Oid>) {
    if !track.new.is_zero() {
        let track = Track {
            old: git2::Oid::zero().into(),
            ..track
        };

        assert_eq!(track.updated(), Updated::Created);
    }
}

fn prop_track_deleted(track: Track<ext::Oid>) {
    if !track.old.is_zero() {
        let track = Track {
            new: git2::Oid::zero().into(),
            ..track
        };

        assert_eq!(track.updated(), Updated::Deleted);
    }
}

fn prop_track_changed(track: Track<ext::Oid>) {
    if !track.old.is_zero() && !track.new.is_zero() && track.old != track.new {
        assert_eq!(track.updated(), Updated::Changed);
    } else {
        assert_eq!(track.updated(), Updated::NoChange);
    }
}

fn prop_data_created(data: Data<ext::Oid>) {
    if !data.new.is_zero() {
        let data = Data {
            old: git2::Oid::zero().into(),
            ..data
        };

        assert_eq!(data.updated(), Updated::Created);
    }
}

fn prop_data_deleted(data: Data<ext::Oid>) {
    if !data.old.is_zero() {
        let data = Data {
            new: git2::Oid::zero().into(),
            ..data
        };
        assert_eq!(data.updated(), Updated::Deleted);
    }
}

fn prop_data_changed(data: Data<ext::Oid>) {
    if !data.old.is_zero() && !data.new.is_zero() && data.old != data.new {
        assert_eq!(data.updated(), Updated::Changed);
    } else {
        assert_eq!(data.updated(), Updated::NoChange);
    }
}
diff --git a/test/Cargo.toml b/test/Cargo.toml
index 6ebc5100..c808347f 100644
--- a/test/Cargo.toml
+++ b/test/Cargo.toml
@@ -57,6 +57,10 @@ features = ["test"]
path = "../link-git/t"
features = ["test"]

[dev-dependencies.link-hooks-test]
path = "../link-hooks/t"
features = ["test"]

[dev-dependencies.link-identities-test]
path = "../link-identities/t"
features = ["test"]
-- 
2.31.1

[PATCH radicle-link v2 3/6] link-hooks Export this patch

The implementation of RFC 703[0].

This crate is broken down into the following components:
* data and track -- for representing the URN and tracking changes that
get communicated to the individual hooks. They are kept generic over
their revision type and can be rendered to and parsed from
strings.
* hook -- the types and traits for running hooks and communicating to
them. A single `Hook` is represented by its path to the executable and
the subprocess it is running. This subprocess must implement the
`Handle` trait which knows how to `spawn` the process, `write` to the
process, and `wait_or_kill` the process. A set of `Hooks` is run using
an unordered manner, getting passed `Notification`s via a stream and
then terminated once the stream is exhausted.

[0]: https://github.com/radicle-dev/radicle-link/blob/master/docs/rfc/0703-storage-hooks.adoc

Signed-off-by: Fintan Halpenny <fintan.halpenny@gmail.com>
---
 Cargo.toml                    |   1 +
 link-hooks/Cargo.toml         |  40 ++++
 link-hooks/src/data.rs        | 114 ++++++++++
 link-hooks/src/hook.rs        | 378 ++++++++++++++++++++++++++++++++++
 link-hooks/src/hook/config.rs |  28 +++
 link-hooks/src/lib.rs         |  59 ++++++
 link-hooks/src/sealed.rs      |   4 +
 link-hooks/src/track.rs       | 135 ++++++++++++
 8 files changed, 759 insertions(+)
 create mode 100644 link-hooks/Cargo.toml
 create mode 100644 link-hooks/src/data.rs
 create mode 100644 link-hooks/src/hook.rs
 create mode 100644 link-hooks/src/hook/config.rs
 create mode 100644 link-hooks/src/lib.rs
 create mode 100644 link-hooks/src/sealed.rs
 create mode 100644 link-hooks/src/track.rs

diff --git a/Cargo.toml b/Cargo.toml
index d56138c3..29c8ae0d 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -13,6 +13,7 @@ members = [
  "link-canonical-derive",
  "link-crypto",
  "link-git",
  "link-hooks",
  "link-identities",
  "link-replication",
  "link-tracking",
diff --git a/link-hooks/Cargo.toml b/link-hooks/Cargo.toml
new file mode 100644
index 00000000..2afd123b
--- /dev/null
+++ b/link-hooks/Cargo.toml
@@ -0,0 +1,40 @@
[package]
name = "link-hooks"
version = "0.1.0"
authors = ["Fintan Halpenny <fintan.halpenny@gmail.com>"]
edition = "2021"
license = "GPL-3.0-or-later"

[lib]
doctest = false
test = false

[features]
git = ["git2", "radicle-git-ext"]

[dependencies]
async-trait = "0.1"
futures = "0.3"
multihash = "0.11"
thiserror = "1"
tracing = "0.1"

[dependencies.git2]
version = "0.13.24"
default-features = false
features = ["vendored-libgit2"]
optional = true

[dependencies.radicle-git-ext]
path = "../git-ext"
optional = true

[dependencies.link-crypto]
path = "../link-crypto"

[dependencies.link-identities]
path = "../link-identities"

[dependencies.tokio]
version = "1.18"
features = ["io-util", "process", "sync", "time"]
diff --git a/link-hooks/src/data.rs b/link-hooks/src/data.rs
new file mode 100644
index 00000000..82511680
--- /dev/null
+++ b/link-hooks/src/data.rs
@@ -0,0 +1,114 @@
// Copyright © 2022 The Radicle Link Contributors
// SPDX-License-Identifier: GPL-3.0-or-later

use std::{fmt, str::FromStr};

use link_identities::urn::{HasProtocol, Urn};
use multihash::Multihash;

use super::{sealed, Display, IsZero, Updated};

#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Data<R> {
    pub urn: Urn<R>,
    pub old: R,
    pub new: R,
}

impl<R> Data<R>
where
    R: IsZero + PartialEq,
{
    pub fn updated(&self) -> Updated {
        match (self.old.is_zero(), self.new.is_zero()) {
            (true, true) => Updated::Zero,
            (true, false) => Updated::Created,
            (false, true) => Updated::Deleted,
            (false, false) if self.old != self.new => Updated::Changed,
            _ => Updated::NoChange,
        }
    }
}

impl<R> fmt::Display for Data<R>
where
    R: HasProtocol + fmt::Display,
    for<'a> &'a R: Into<Multihash>,
{
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "{} ", self.urn)?;

        writeln!(f, "{} {}", self.old, self.new)
    }
}

impl<R> sealed::Sealed for Data<R> {}
impl<R> Display for Data<R>
where
    R: HasProtocol + fmt::Display,
    for<'a> &'a R: Into<Multihash>,
{
    fn display(&self) -> String {
        self.to_string()
    }
}

impl<R, E> FromStr for Data<R>
where
    R: HasProtocol + TryFrom<Multihash, Error = E> + FromStr,
    R::Err: std::error::Error + Send + Sync + 'static,
    E: std::error::Error + Send + Sync + 'static,
{
    type Err = error::Parse<E>;

    fn from_str(s: &str) -> Result<Self, Self::Err> {
        let mut components = s.split(' ');

        let urn = match components.next() {
            Some(urn) => urn.parse::<Urn<R>>()?,
            None => return Err(error::Parse::Missing("rad:git:<identitifier>[/<path>]")),
        };

        let old = match components.next() {
            Some(old) => old
                .parse::<R>()
                .map_err(|err| error::Parse::Revision(Box::new(err)))?,
            None => return Err(error::Parse::Missing("<old>")),
        };

        let new = match components.next() {
            Some(new) => match new.strip_suffix('\n') {
                None => return Err(error::Parse::Newline(new.to_string())),
                Some(new) => new
                    .parse::<R>()
                    .map_err(|err| error::Parse::Revision(Box::new(err)))?,
            },
            None => return Err(error::Parse::Missing("<new> LF")),
        };

        if let Some(extra) = components.next() {
            return Err(error::Parse::Extra(extra.to_string()));
        }

        Ok(Self { urn, old, new })
    }
}

pub mod error {
    use link_identities::urn;
    use thiserror::Error;

    #[derive(Debug, Error)]
    pub enum Parse<E: std::error::Error + Send + Sync + 'static> {
        #[error("found extra data {0}")]
        Extra(String),
        #[error("missing component {0}")]
        Missing(&'static str),
        #[error("expected newline, but found {0}")]
        Newline(String),
        #[error(transparent)]
        Revision(Box<dyn std::error::Error + Send + Sync + 'static>),
        #[error(transparent)]
        Urn(#[from] urn::error::FromStr<E>),
    }
}
diff --git a/link-hooks/src/hook.rs b/link-hooks/src/hook.rs
new file mode 100644
index 00000000..298e258f
--- /dev/null
+++ b/link-hooks/src/hook.rs
@@ -0,0 +1,378 @@
// Copyright © 2022 The Radicle Link Contributors
// SPDX-License-Identifier: GPL-3.0-or-later

use std::{collections::HashMap, ffi::OsStr, fmt, path::PathBuf, str::FromStr, time::Duration};

use futures::{future::BoxFuture, stream::FuturesUnordered, FutureExt, Stream, StreamExt as _};
use multihash::Multihash;
use tokio::sync::mpsc;

use link_identities::urn::HasProtocol;

use super::{Data, Display, Track};

pub mod config;
pub use config::Config;

/// End of transimission character.
pub const EOT: u8 = 0x04;

/// A notification sent by the notifying process to the set of hook processes.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Notification<R> {
    Track(Track<R>),
    Data(Data<R>),
}

impl<R> fmt::Display for Notification<R>
where
    R: HasProtocol + fmt::Display,
    for<'a> &'a R: Into<Multihash>,
{
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::Track(track) => write!(f, "{}", track),
            Self::Data(data) => write!(f, "{}", data),
        }
    }
}

impl<R> From<Track<R>> for Notification<R> {
    fn from(t: Track<R>) -> Self {
        Self::Track(t)
    }
}

impl<R> From<Data<R>> for Notification<R> {
    fn from(t: Data<R>) -> Self {
        Self::Data(t)
    }
}

/// Executor for a set of [`Hook`]s that will receive and process
/// [`Notification`]s via a channel.
pub struct Hooks<P: Process> {
    data_hooks: Vec<Hook<P>>,
    track_hooks: Vec<Hook<P>>,
    config: Config,
}

impl<P: Process + Send + Sync + 'static> Hooks<P> {
    /// Construct the `Hooks` runner.
    ///
    /// To create a [`Hook`], use the [`Process::spawn`] constructor, where the
    /// child process of the `Hook` must also implement [`Process`].
    ///
    /// To start the hooks routine and process [`Notification`]s, use
    /// [`Hooks::run`].
    pub fn new(config: Config, data_hooks: Vec<Hook<P>>, track_hooks: Vec<Hook<P>>) -> Self {
        Self {
            data_hooks,
            track_hooks,
            config,
        }
    }

    /// The `incoming` [`Notification`]s are sent to each respective hook,
    /// depending on the notification variant, until the stream is exhausted.
    ///
    /// Once the stream is complete, the end-of-transmission character is sent
    /// to every hook to signal that they should stop. The hook is given a
    /// grace period to stop and exit, otherwise it will be terminated after the
    /// timeout given in the [`Config`].
    pub async fn run<S, R>(self, mut incoming: S)
    where
        R: Clone + HasProtocol + std::fmt::Display + Send + Sync + 'static,
        for<'b> &'b R: Into<Multihash>,
        S: Stream<Item = Notification<R>> + Unpin,
    {
        use senders::{Event, Senders};

        let mut routines = FuturesUnordered::new();
        let mut data_senders: Senders<Data<R>> = Senders::new(Event::Data);
        let mut track_senders: Senders<Track<R>> = Senders::new(Event::Track);

        for hook in self.data_hooks {
            let path = hook.path.clone();
            tracing::debug!(hook = %path.display(), "starting data hook");
            let (sender, routine) = hook.start(self.config.hook);
            data_senders.insert(path, sender);
            routines.push(routine);
        }
        for hook in self.track_hooks {
            let path = hook.path.clone();
            tracing::debug!(hook = %path.display(), "starting track hook");
            let (sender, routine) = hook.start(self.config.hook);
            track_senders.insert(path, sender);
            routines.push(routine);
        }
        loop {
            futures::select! {
                failed_hook_path = routines.next().fuse() => {
                    if let Some(failed_hook_path) = failed_hook_path {
                        tracing::warn!(hook = %failed_hook_path.display(), "hook failed, removing from hooks set");
                        data_senders.remove(&failed_hook_path);
                        track_senders.remove(&failed_hook_path);
                    } else {
                        tracing::error!("all hook routines have stopped");
                        break;
                    }
                }
                n = incoming.next().fuse() => {
                    match n {
                        Some(Notification::Data(d)) => {
                            tracing::trace!(data = %d, "received data notification");
                            data_senders.send(d)
                        },
                        Some(Notification::Track(t)) => {
                            tracing::trace!(track = %t, "received track notification");
                            track_senders.send(t)
                        },
                        None => {
                            tracing::trace!("finished notifications stream");
                            break
                        },
                    }
                },
            }
        }

        // Send EOTs to all senders
        data_senders.eot().await;
        track_senders.eot().await;

        // Wait for routines to complete
        for routine in routines {
            let path = routine.await;
            tracing::info!(hook = %path.display(), "hook finished");
        }
    }
}

/// A communication medium for a hook process.
///
/// # Cancel Safety
///
/// Since the cancel safety is based on the implementing data type of `Handle`,
/// it should be assumed that the methods are *not* cancel safe.
#[async_trait]
pub trait Process: Sized {
    type SpawnError: std::error::Error + Send + Sync + 'static;
    type WriteError: std::error::Error + Send + Sync + 'static;
    type DieError: std::error::Error + Send + Sync + 'static;

    /// Spawn a new hook process where `path` points to the hook executable. The
    /// `args` should typically be `None::<String>`, but can be used for testing
    /// purposes.
    async fn spawn<I, S>(path: PathBuf, args: I) -> Result<Self, Self::SpawnError>
    where
        I: IntoIterator<Item = S> + Send,
        S: AsRef<OsStr>;

    /// Write data to the hook process.
    async fn write(&mut self, bs: &[u8]) -> Result<(), Self::WriteError>;

    /// Wait for the hook process to finish, or kill after `duration`.
    async fn wait_or_kill(&mut self, duration: Duration) -> Result<(), Self::DieError>;
}

/// A spawned hook process.
pub struct Hook<P: Process> {
    path: PathBuf,
    child: P,
}

pub enum HookMessage<T> {
    /// End of transmission message.
    EOT,
    /// The payload to be sent to a hook, usually [`Data`] or [`Track`].
    Payload(T),
}

impl<T> From<T> for HookMessage<T> {
    fn from(t: T) -> Self {
        Self::Payload(t)
    }
}

impl<T: FromStr> FromStr for HookMessage<T> {
    type Err = T::Err;

    fn from_str(s: &str) -> Result<Self, Self::Err> {
        if s == String::from_utf8(vec![EOT]).expect("BUG: EOT is valid utf-8") {
            Ok(Self::EOT)
        } else {
            s.parse().map(Self::Payload)
        }
    }
}

impl<P: Process + Send + Sync + 'static> Hook<P> {
    pub fn new(path: PathBuf, child: P) -> Self {
        Self { path, child }
    }

    #[tracing::instrument(skip(self), fields(hook = ?self.path))]
    pub fn start<'a, D>(
        mut self,
        config: config::Hook,
    ) -> (mpsc::Sender<HookMessage<D>>, BoxFuture<'a, PathBuf>)
    where
        D: Display + Send + Sync + 'static,
    {
        let (sx, mut rx) = mpsc::channel::<HookMessage<D>>(config.buffer);
        let routine = async move {
            tracing::trace!("waiting for notification");
            while let Some(msg) = rx.recv().await {
                match msg {
                    HookMessage::EOT => {
                        if let Err(err) = self.write(&[EOT]).await {
                            tracing::warn!(err = %err, "failed to write EOT to hook");
                        }
                        if let Err(err) = self.wait_or_kill(config.timeout).await {
                            tracing::warn!(err = %err, "failed to terminate hook");
                        }
                        return self.path;
                    },
                    HookMessage::Payload(msg) => {
                        if let Err(err) = self.write(msg.display().as_bytes()).await {
                            tracing::warn!(err = %err, "failed to write to hook");
                            return self.path;
                        }
                    },
                }
            }
            self.path
        }
        .boxed();
        (sx, routine)
    }
}

#[async_trait]
impl<P> Process for Hook<P>
where
    P: Process + Send + Sync + 'static,
{
    type WriteError = P::WriteError;
    type SpawnError = P::SpawnError;
    type DieError = P::DieError;

    async fn spawn<I, S>(path: PathBuf, args: I) -> Result<Self, Self::SpawnError>
    where
        I: IntoIterator<Item = S> + Send,
        S: AsRef<OsStr>,
    {
        Ok(Self {
            path: path.clone(),
            child: P::spawn(path, args).await?,
        })
    }

    async fn write(&mut self, bs: &[u8]) -> Result<(), Self::WriteError> {
        self.child.write(bs).await
    }

    async fn wait_or_kill(&mut self, duration: Duration) -> Result<(), Self::DieError> {
        self.child.wait_or_kill(duration).await
    }
}

pub(super) mod senders {
    use super::*;

    #[derive(Debug)]
    pub enum Event {
        Track,
        Data,
    }

    pub struct Senders<P> {
        senders: HashMap<PathBuf, mpsc::Sender<HookMessage<P>>>,
        kind: Event,
    }

    impl<P> Senders<P> {
        pub fn new(kind: Event) -> Self {
            Self {
                senders: HashMap::new(),
                kind,
            }
        }

        pub fn insert(&mut self, path: PathBuf, sender: mpsc::Sender<HookMessage<P>>) {
            self.senders.insert(path, sender);
        }

        pub fn remove(&mut self, path: &PathBuf) {
            self.senders.remove(path);
        }

        pub fn send(&self, p: P)
        where
            P: Clone,
        {
            for (path, sender) in self.senders.iter() {
                if sender.try_send(p.clone().into()).is_err() {
                    tracing::warn!(hook=%path.display(), kind=?self.kind, "dropping message for hook which is running too slowly");
                }
            }
        }

        pub async fn eot(&self) {
            for (path, sender) in self.senders.iter() {
                if let Err(err) = sender.send(HookMessage::EOT).await {
                    tracing::warn!(hook=%path.display(), kind=?self.kind, err=%err, "failed to send EOT");
                }
            }
        }
    }
}

mod tokio_impl {
    use std::{ffi::OsStr, io, path::PathBuf, process::Stdio, time::Duration};
    use tokio::{
        io::AsyncWriteExt,
        process::{Child, Command},
    };

    use super::Process;

    #[async_trait]
    impl Process for Child {
        type WriteError = io::Error;
        type SpawnError = io::Error;
        type DieError = io::Error;

        async fn spawn<I, S>(path: PathBuf, args: I) -> Result<Self, Self::SpawnError>
        where
            I: IntoIterator<Item = S> + Send,
            S: AsRef<OsStr>,
        {
            // TODO: figure out how to pipe stdout/stderr to tracing
            let child = Command::new(path)
                .stdin(Stdio::piped())
                .stdout(Stdio::null())
                .stderr(Stdio::null())
                .args(args)
                .spawn()?;
            Ok(child)
        }

        async fn write(&mut self, bs: &[u8]) -> Result<(), Self::WriteError> {
            self.stdin
                .as_mut()
                .expect("BUG: stdin was not set up for subprocess")
                .write_all(bs)
                .await
        }

        async fn wait_or_kill(&mut self, duration: Duration) -> Result<(), Self::DieError> {
            if tokio::time::timeout(duration, self.wait()).await.is_err() {
                self.kill().await
            } else {
                Ok(())
            }
        }
    }
}
diff --git a/link-hooks/src/hook/config.rs b/link-hooks/src/hook/config.rs
new file mode 100644
index 00000000..520d9f40
--- /dev/null
+++ b/link-hooks/src/hook/config.rs
@@ -0,0 +1,28 @@
// Copyright © 2022 The Radicle Link Contributors
// SPDX-License-Identifier: GPL-3.0-or-later

use std::time::Duration;

#[derive(Clone, Copy, Debug, Default)]
pub struct Config {
    /// Configuration for the set of [`super::Hooks`]
    pub hook: Hook,
}

#[derive(Clone, Copy, Debug)]
pub struct Hook {
    /// The buffer size for the hook's internal channel.
    pub buffer: usize,
    /// The duration to wait for a hook to complete after the
    /// end-of-transmission message before it is forcefully killed.
    pub timeout: Duration,
}

impl Default for Hook {
    fn default() -> Self {
        Self {
            buffer: 10,
            timeout: Duration::from_secs(2),
        }
    }
}
diff --git a/link-hooks/src/lib.rs b/link-hooks/src/lib.rs
new file mode 100644
index 00000000..6edca5b7
--- /dev/null
+++ b/link-hooks/src/lib.rs
@@ -0,0 +1,59 @@
// Copyright © 2022 The Radicle Link Contributors
// SPDX-License-Identifier: GPL-3.0-or-later

#[macro_use]
extern crate async_trait;

pub mod data;
pub use data::Data;

pub mod track;
pub use track::Track;

pub mod hook;
pub use hook::{Hooks, Notification};

mod sealed;

pub trait Display: sealed::Sealed {
    fn display(&self) -> String;
}

pub trait IsZero {
    fn is_zero(&self) -> bool;
}

/// The updated summary of the `old` and `new` revisions.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum Updated {
    /// Both `old` and `new` are the zero revision.
    Zero,
    /// `old` is the zero revision and `new` is a non-zero revision.
    Created,
    /// `old` is a non-zero revision and `new` is the zero revision.
    Deleted,
    /// Both `old` and `new` are non-zero revisions.
    Changed,
    /// Both `old` and `new` are the same non-zero revision.
    NoChange,
}

#[cfg(feature = "git")]
mod git {
    use git2::Oid;
    use radicle_git_ext as ext;

    use super::IsZero;

    impl IsZero for Oid {
        fn is_zero(&self) -> bool {
            self == &Oid::zero()
        }
    }

    impl IsZero for ext::Oid {
        fn is_zero(&self) -> bool {
            git2::Oid::from(*self).is_zero()
        }
    }
}
diff --git a/link-hooks/src/sealed.rs b/link-hooks/src/sealed.rs
new file mode 100644
index 00000000..d5ae7675
--- /dev/null
+++ b/link-hooks/src/sealed.rs
@@ -0,0 +1,4 @@
// Copyright © 2022 The Radicle Link Contributors
// SPDX-License-Identifier: GPL-3.0-or-later

pub trait Sealed {}
diff --git a/link-hooks/src/track.rs b/link-hooks/src/track.rs
new file mode 100644
index 00000000..e4e2e795
--- /dev/null
+++ b/link-hooks/src/track.rs
@@ -0,0 +1,135 @@
// Copyright © 2022 The Radicle Link Contributors
// SPDX-License-Identifier: GPL-3.0-or-later

use std::{fmt, str::FromStr};

use link_crypto::PeerId;
use link_identities::urn::{HasProtocol, Urn};
use multihash::Multihash;

use super::{sealed, Display, IsZero, Updated};

#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Track<R> {
    pub urn: Urn<R>,
    pub peer: Option<PeerId>,
    pub old: R,
    pub new: R,
}

impl<R> Track<R>
where
    R: IsZero + PartialEq,
{
    pub fn updated(&self) -> Updated {
        match (self.old.is_zero(), self.new.is_zero()) {
            (true, true) => Updated::Zero,
            (true, false) => Updated::Created,
            (false, true) => Updated::Deleted,
            (false, false) if self.old != self.new => Updated::Changed,
            _ => Updated::NoChange,
        }
    }
}

impl<R> fmt::Display for Track<R>
where
    R: HasProtocol + fmt::Display,
    for<'a> &'a R: Into<Multihash>,
{
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "{} ", self.urn)?;

        match self.peer {
            None => write!(f, "default "),
            Some(peer) => write!(f, "{} ", peer),
        }?;

        writeln!(f, "{} {}", self.old, self.new)
    }
}

impl<R> sealed::Sealed for Track<R> {}
impl<R> Display for Track<R>
where
    R: HasProtocol + fmt::Display,
    for<'a> &'a R: Into<Multihash>,
{
    fn display(&self) -> String {
        self.to_string()
    }
}

impl<R, E> FromStr for Track<R>
where
    R: HasProtocol + TryFrom<Multihash, Error = E> + FromStr,
    R::Err: std::error::Error + Send + Sync + 'static,
    E: std::error::Error + Send + Sync + 'static,
{
    type Err = error::Parse<E>;

    fn from_str(s: &str) -> Result<Self, Self::Err> {
        let mut components = s.split(' ');

        let urn = match components.next() {
            Some(urn) => urn.parse::<Urn<R>>()?,
            None => return Err(error::Parse::Missing("rad:git:<identitifier>[/<path>]")),
        };

        let peer = match components.next() {
            Some("default") => None,
            Some(peer) => peer.parse().map(Some)?,
            None => return Err(error::Parse::Missing("<peer id>")),
        };

        let old = match components.next() {
            Some(old) => old
                .parse::<R>()
                .map_err(|err| error::Parse::Revision(Box::new(err)))?,
            None => return Err(error::Parse::Missing("<old>")),
        };

        let new = match components.next() {
            Some(new) => match new.strip_suffix('\n') {
                None => return Err(error::Parse::Newline(new.to_string())),
                Some(new) => new
                    .parse::<R>()
                    .map_err(|err| error::Parse::Revision(Box::new(err)))?,
            },
            None => return Err(error::Parse::Missing("<new> LF")),
        };

        if let Some(extra) = components.next() {
            return Err(error::Parse::Extra(extra.to_string()));
        }

        Ok(Self {
            urn,
            peer,
            old,
            new,
        })
    }
}

pub mod error {
    use link_crypto::peer;
    use link_identities::urn;
    use thiserror::Error;

    #[derive(Debug, Error)]
    pub enum Parse<E: std::error::Error + Send + Sync + 'static> {
        #[error("found extra data {0}")]
        Extra(String),
        #[error("missing component {0}")]
        Missing(&'static str),
        #[error("expected newline, but found {0}")]
        Newline(String),
        #[error(transparent)]
        Peer(#[from] peer::conversion::Error),
        #[error(transparent)]
        Revision(Box<dyn std::error::Error + Send + Sync + 'static>),
        #[error(transparent)]
        Urn(#[from] urn::error::FromStr<E>),
    }
}
-- 
2.31.1

[PATCH radicle-link v1 4/5] link-hooks: smoke test running hooks Export this patch

Add smoke tests for executing a number of hooks.

This is achieved by adding executables under `test/hooks`:
* `echo-data` - handles `Data` messages
* `echo-track` - handles `Track` messages
* `echo-forever` - acts a misbehaving hook and sleeps to simulate a
  hang

The tests pass file handles to the corresponding hook and ensure that
all the notifications were written to the file once the `Hooks`
process is finished.

In the case of using `echo-forever`, we are ensuring that a hanging
hook does not poison the `Hooks` process and all hooks complete their
respective tasks.

Signed-off-by: Fintan Halpenny <fintan.halpenny@gmail.com>
---
 link-hooks/t/Cargo.toml               |  13 +++
 link-hooks/t/src/integration.rs       |   4 +
 link-hooks/t/src/integration/smoke.rs | 156 ++++++++++++++++++++++++++
 link-hooks/t/src/lib.rs               |   2 +
 test/hooks/Cargo.toml                 |   6 +
 test/hooks/echo-data/Cargo.toml       |  10 ++
 test/hooks/echo-data/src/main.rs      |  32 ++++++
 test/hooks/echo-forever/Cargo.toml    |  10 ++
 test/hooks/echo-forever/src/main.rs   |   6 +
 test/hooks/echo-track/Cargo.toml      |  10 ++
 test/hooks/echo-track/src/main.rs     |  32 ++++++
 11 files changed, 281 insertions(+)
 create mode 100644 link-hooks/t/src/integration.rs
 create mode 100644 link-hooks/t/src/integration/smoke.rs
 create mode 100644 test/hooks/Cargo.toml
 create mode 100644 test/hooks/echo-data/Cargo.toml
 create mode 100644 test/hooks/echo-data/src/main.rs
 create mode 100644 test/hooks/echo-forever/Cargo.toml
 create mode 100644 test/hooks/echo-forever/src/main.rs
 create mode 100644 test/hooks/echo-track/Cargo.toml
 create mode 100644 test/hooks/echo-track/src/main.rs

diff --git a/link-hooks/t/Cargo.toml b/link-hooks/t/Cargo.toml
index 2c3d2a14..fe867a12 100644
--- a/link-hooks/t/Cargo.toml
+++ b/link-hooks/t/Cargo.toml
@@ -40,5 +40,18 @@ features = ["test"]
[dependencies.radicle-git-ext]
path = "../../git-ext"

[dev-dependencies.link-async]
path = "../../link-async"

[dev-dependencies.futures]
version = "0.3"

[dev-dependencies.tempfile]
version = "3.3"

[dev-dependencies.test-helpers]
path = "../../test/test-helpers"

[dev-dependencies.tokio]
version = "1.18"
features = ["macros", "rt", "sync"]
diff --git a/link-hooks/t/src/integration.rs b/link-hooks/t/src/integration.rs
new file mode 100644
index 00000000..d25f269d
--- /dev/null
+++ b/link-hooks/t/src/integration.rs
@@ -0,0 +1,4 @@
// Copyright © 2022 The Radicle Link Contributors
// SPDX-License-Identifier: GPL-3.0-or-later

mod smoke;
diff --git a/link-hooks/t/src/integration/smoke.rs b/link-hooks/t/src/integration/smoke.rs
new file mode 100644
index 00000000..a47823a5
--- /dev/null
+++ b/link-hooks/t/src/integration/smoke.rs
@@ -0,0 +1,156 @@
// Copyright © 2022 The Radicle Link Contributors
// SPDX-License-Identifier: GPL-3.0-or-later

//! These tests rely on the executables found in `test/hooks`. There are three
//! executables:
//!   * `echo-data` - parses `Data` and writes it to the file path passed as an
//!     argument.
//!   * `echo-track` - parses `Track` and writes it to the file path passed as
//!     an argument.
//!   * `echo-forever` - hangs for 10s to ensure other hooks continue
//!     processing.

use std::{
    io::Read as _,
    iter,
    path::{Path, PathBuf},
};

use link_hooks::{
    hook::{self, Handle, Hook},
    Data,
    Hooks,
    Notification,
    Track,
};
use radicle_git_ext::Oid;
use tempfile::NamedTempFile;
use test_helpers::logging;
use tokio::process::Child;

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_echo_hooks() {
    logging::init();

    let data_hook_path = setup_hook("data");
    let track_hook_path = setup_hook("track");
    let mut data_out = NamedTempFile::new().unwrap();
    let mut track_out = NamedTempFile::new().unwrap();
    let data_hooks = vec![Hook::<Child>::spawn(
        data_hook_path,
        Some(format!("{}", data_out.path().display())),
    )
    .await
    .unwrap()];
    let track_hooks = vec![Hook::<Child>::spawn(
        track_hook_path,
        Some(format!("{}", track_out.path().display())),
    )
    .await
    .unwrap()];

    let hooks = Hooks::new(hook::Config::default(), data_hooks, track_hooks);
    assert_notifications(hooks, &mut data_out, &mut track_out).await
}

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_hanging_hook() {
    logging::init();

    let data_hook_path = setup_hook("data");
    let track_hook_path = setup_hook("track");
    let forever_hook_path = setup_hook("forever");
    let mut data_out = NamedTempFile::new().unwrap();
    let mut track_out = NamedTempFile::new().unwrap();
    let data_hooks = vec![
        Hook::<Child>::spawn(
            data_hook_path,
            Some(format!("{}", data_out.path().display())),
        )
        .await
        .unwrap(),
        Hook::<Child>::spawn(forever_hook_path, None::<String>)
            .await
            .unwrap(),
    ];
    let track_hooks = vec![Hook::<Child>::spawn(
        track_hook_path,
        Some(format!("{}", track_out.path().display())),
    )
    .await
    .unwrap()];

    let hooks = Hooks::new(hook::Config::default(), data_hooks, track_hooks);
    assert_notifications(hooks, &mut data_out, &mut track_out).await
}

async fn assert_notifications(
    hooks: Hooks<Child>,
    data_out: &mut NamedTempFile,
    track_out: &mut NamedTempFile,
) {
    let notifications = vec![
        "rad:git:hnrkyzfpih4pqsw3cp1donkmwsgh9w5fwfdwo/refs/heads/main 0c3b4502a83a309b19123adc60a23e4e92bb13fb aeff7e8e964c47ba67a0c6eeba3beb62e29379d4\n".parse::<Data<Oid>>().unwrap().into(),
        "rad:git:hnrkyzfpih4pqsw3cp1donkmwsgh9w5fwfdwo hyyqpngdoe4x4oto3emfdppbw7sj1pfaghbpmmhz5rqiuqg8uofmeo 0c3b4502a83a309b19123adc60a23e4e92bb13fb aeff7e8e964c47ba67a0c6eeba3beb62e29379d4\n".parse::<Track<Oid>>().unwrap().into(),
        "rad:git:hnrkyzfpih4pqsw3cp1donkmwsgh9w5fwfdwo default 0c3b4502a83a309b19123adc60a23e4e92bb13fb aeff7e8e964c47ba67a0c6eeba3beb62e29379d4\n".parse::<Track<Oid>>().unwrap().into(),
        ];

    hooks
        .run(futures::stream::iter(notifications.clone()))
        .await;

    let expected = {
        let mut buf = String::new();
        data_out.read_to_string(&mut buf).unwrap();
        let expected = iter::once(Notification::from(buf.parse::<Data<Oid>>().unwrap()));

        let mut buf = String::new();
        track_out.read_to_string(&mut buf).unwrap();
        expected
            .chain(buf.split('\n').filter_map(|track| {
                if !track.is_empty() {
                    let mut track = track.to_owned();
                    track.push('\n');
                    Some(Notification::from(track.parse::<Track<Oid>>().unwrap()))
                } else {
                    None
                }
            }))
            .collect::<Vec<_>>()
    };

    assert_eq!(notifications, expected);
}

fn setup_hook(hook: &str) -> PathBuf {
    let test_path = Path::new(env!("CARGO_MANIFEST_DIR"));
    let root = test_path
        .parent()
        .unwrap()
        .parent()
        .unwrap()
        .join("test/hooks");
    let manifest = root.join(format!("echo-{}", hook)).join("Cargo.toml");
    let hook_path = root
        .join("target")
        .join("debug")
        .join(format!("echo-{}", hook));

    if !hook_path.exists() {
        let out = std::process::Command::new("cargo")
            .args(&[
                "build",
                "--bin",
                &format!("echo-{}", hook),
                "--manifest-path",
                &format!("{}", manifest.display()),
            ])
            .output()
            .unwrap();
        if !out.status.success() {
            println!("{:#?}", out)
        }
    }

    hook_path
}
diff --git a/link-hooks/t/src/lib.rs b/link-hooks/t/src/lib.rs
index 16452050..af4f8834 100644
--- a/link-hooks/t/src/lib.rs
+++ b/link-hooks/t/src/lib.rs
@@ -4,5 +4,7 @@
#[cfg(any(test, feature = "test"))]
pub mod gen;

#[cfg(test)]
mod integration;
#[cfg(test)]
mod properties;
diff --git a/test/hooks/Cargo.toml b/test/hooks/Cargo.toml
new file mode 100644
index 00000000..b2389438
--- /dev/null
+++ b/test/hooks/Cargo.toml
@@ -0,0 +1,6 @@
[workspace]
members = [
  "echo-data",
  "echo-forever",
  "echo-track",
]
diff --git a/test/hooks/echo-data/Cargo.toml b/test/hooks/echo-data/Cargo.toml
new file mode 100644
index 00000000..a7868f23
--- /dev/null
+++ b/test/hooks/echo-data/Cargo.toml
@@ -0,0 +1,10 @@
[package]
name = "echo-data"
version = "0.1.0"
edition = "2021"

[dependencies.link-hooks]
path = "../../../link-hooks"

[dependencies.radicle-git-ext]
path = "../../../git-ext"
\ No newline at end of file
diff --git a/test/hooks/echo-data/src/main.rs b/test/hooks/echo-data/src/main.rs
new file mode 100644
index 00000000..d95b3fc9
--- /dev/null
+++ b/test/hooks/echo-data/src/main.rs
@@ -0,0 +1,32 @@
use std::{
    env,
    fs,
    io::{self, Write as _},
};

use link_hooks::{hook::HookMessage, Data};
use radicle_git_ext::Oid;

type Message = HookMessage<Data<Oid>>;

fn main() {
    let mut args = env::args();
    let _ = args.next();
    let out = args.next().expect("expected output path");
    let mut file = fs::File::create(out).unwrap();

    let mut buffer = String::new();
    let stdin = io::stdin();
    let mut eot = false;

    while !eot {
        stdin.read_line(&mut buffer).unwrap();
        match buffer.parse::<Message>().unwrap() {
            HookMessage::EOT => {
                eot = true;
            },
            HookMessage::Payload(data) => file.write_all(format!("{}", data).as_bytes()).unwrap(),
        }
        buffer.clear();
    }
}
diff --git a/test/hooks/echo-forever/Cargo.toml b/test/hooks/echo-forever/Cargo.toml
new file mode 100644
index 00000000..e786d408
--- /dev/null
+++ b/test/hooks/echo-forever/Cargo.toml
@@ -0,0 +1,10 @@
[package]
name = "echo-forever"
version = "0.1.0"
edition = "2021"

[dependencies.link-hooks]
path = "../../../link-hooks"

[dependencies.radicle-git-ext]
path = "../../../git-ext"
diff --git a/test/hooks/echo-forever/src/main.rs b/test/hooks/echo-forever/src/main.rs
new file mode 100644
index 00000000..9a26a8af
--- /dev/null
+++ b/test/hooks/echo-forever/src/main.rs
@@ -0,0 +1,6 @@
use std::{thread, time::Duration};

fn main() {
    // simulate a hook hanging
    thread::sleep(Duration::from_secs(10));
}
diff --git a/test/hooks/echo-track/Cargo.toml b/test/hooks/echo-track/Cargo.toml
new file mode 100644
index 00000000..172cee8e
--- /dev/null
+++ b/test/hooks/echo-track/Cargo.toml
@@ -0,0 +1,10 @@
[package]
name = "echo-track"
version = "0.1.0"
edition = "2021"

[dependencies.link-hooks]
path = "../../../link-hooks"

[dependencies.radicle-git-ext]
path = "../../../git-ext"
\ No newline at end of file
diff --git a/test/hooks/echo-track/src/main.rs b/test/hooks/echo-track/src/main.rs
new file mode 100644
index 00000000..5ea033a6
--- /dev/null
+++ b/test/hooks/echo-track/src/main.rs
@@ -0,0 +1,32 @@
use std::{
    env,
    fs,
    io::{self, Write as _},
};

use link_hooks::{hook::HookMessage, Track};
use radicle_git_ext::Oid;

type Message = HookMessage<Track<Oid>>;

fn main() {
    let mut args = env::args();
    let _ = args.next();
    let out = args.next().expect("expected output path");
    let mut file = fs::File::create(out).unwrap();

    let mut buffer = String::new();
    let stdin = io::stdin();
    let mut eot = false;

    while !eot {
        stdin.read_line(&mut buffer).unwrap();
        match buffer.parse::<Message>().unwrap() {
            HookMessage::EOT => {
                eot = true;
            },
            HookMessage::Payload(track) => file.write_all(format!("{}", track).as_bytes()).unwrap(),
        }
        buffer.clear();
    }
}
-- 
2.31.1

[PATCH radicle-link v2 4/6] link-hooks: property tests Export this patch

Add property tests for roundtrip of the `Data` & `Track` data types
and their APIs.

Signed-off-by: Fintan Halpenny <fintan.halpenny@gmail.com>
---
 link-hooks/t/Cargo.toml        | 44 +++++++++++++++
 link-hooks/t/src/gen.rs        |  5 ++
 link-hooks/t/src/gen/data.rs   | 15 ++++++
 link-hooks/t/src/gen/track.rs  | 29 ++++++++++
 link-hooks/t/src/lib.rs        |  8 +++
 link-hooks/t/src/properties.rs | 99 ++++++++++++++++++++++++++++++++++
 test/Cargo.toml                |  4 ++
 7 files changed, 204 insertions(+)
 create mode 100644 link-hooks/t/Cargo.toml
 create mode 100644 link-hooks/t/src/gen.rs
 create mode 100644 link-hooks/t/src/gen/data.rs
 create mode 100644 link-hooks/t/src/gen/track.rs
 create mode 100644 link-hooks/t/src/lib.rs
 create mode 100644 link-hooks/t/src/properties.rs

diff --git a/link-hooks/t/Cargo.toml b/link-hooks/t/Cargo.toml
new file mode 100644
index 00000000..2c3d2a14
--- /dev/null
+++ b/link-hooks/t/Cargo.toml
@@ -0,0 +1,44 @@
[package]
name = "link-hooks-test"
version = "0.1.0"
edition = "2021"
license = "GPL-3.0-or-later"

publish = false

[lib]
doctest = false
test = true
doc = false

[features]
test = []

[dependencies]
proptest = "1"

[dependencies.git2]
version = "0.13.24"
default-features = false
features = ["vendored-libgit2"]

[dependencies.link-crypto]
path = "../../link-crypto"

[dependencies.link-crypto-test]
path = "../../link-crypto/t"
features = ["test"]

[dependencies.link-hooks]
path = "../../link-hooks"
features = ["git"]

[dependencies.link-identities-test]
path = "../../link-identities/t"
features = ["test"]

[dependencies.radicle-git-ext]
path = "../../git-ext"

[dev-dependencies.test-helpers]
path = "../../test/test-helpers"
diff --git a/link-hooks/t/src/gen.rs b/link-hooks/t/src/gen.rs
new file mode 100644
index 00000000..a83564bb
--- /dev/null
+++ b/link-hooks/t/src/gen.rs
@@ -0,0 +1,5 @@
// Copyright © 2022 The Radicle Link Contributors
// SPDX-License-Identifier: GPL-3.0-or-later

pub mod data;
pub mod track;
diff --git a/link-hooks/t/src/gen/data.rs b/link-hooks/t/src/gen/data.rs
new file mode 100644
index 00000000..19b5795b
--- /dev/null
+++ b/link-hooks/t/src/gen/data.rs
@@ -0,0 +1,15 @@
// Copyright © 2022 The Radicle Link Contributors
// SPDX-License-Identifier: GPL-3.0-or-later

use proptest::prelude::*;

use link_hooks::Data;
use link_identities_test::gen::urn::{gen_oid_with_zero, gen_urn};
use radicle_git_ext as ext;

pub fn gen_data() -> impl Strategy<Value = Data<ext::Oid>> {
    gen_oid_with_zero(git2::ObjectType::Commit).prop_flat_map(move |old| {
        gen_oid_with_zero(git2::ObjectType::Commit)
            .prop_flat_map(move |new| gen_urn().prop_map(move |urn| Data { urn, old, new }))
    })
}
diff --git a/link-hooks/t/src/gen/track.rs b/link-hooks/t/src/gen/track.rs
new file mode 100644
index 00000000..78349d41
--- /dev/null
+++ b/link-hooks/t/src/gen/track.rs
@@ -0,0 +1,29 @@
// Copyright © 2022 The Radicle Link Contributors
// SPDX-License-Identifier: GPL-3.0-or-later

use proptest::prelude::*;

use link_crypto::PeerId;
use link_crypto_test::gen::gen_peer_id;
use link_hooks::Track;
use link_identities_test::gen::urn::{gen_oid_with_zero, gen_urn};
use radicle_git_ext as ext;

pub fn gen_track() -> impl Strategy<Value = Track<ext::Oid>> {
    default_or_peer().prop_flat_map(move |peer| {
        gen_oid_with_zero(git2::ObjectType::Commit).prop_flat_map(move |old| {
            gen_oid_with_zero(git2::ObjectType::Commit).prop_flat_map(move |new| {
                gen_urn().prop_map(move |urn| Track {
                    urn,
                    peer,
                    old,
                    new,
                })
            })
        })
    })
}

fn default_or_peer() -> impl Strategy<Value = Option<PeerId>> {
    prop_oneof![Just(None), gen_peer_id().prop_map(Some)]
}
diff --git a/link-hooks/t/src/lib.rs b/link-hooks/t/src/lib.rs
new file mode 100644
index 00000000..16452050
--- /dev/null
+++ b/link-hooks/t/src/lib.rs
@@ -0,0 +1,8 @@
// Copyright © 2022 The Radicle Link Contributors
// SPDX-License-Identifier: GPL-3.0-or-later

#[cfg(any(test, feature = "test"))]
pub mod gen;

#[cfg(test)]
mod properties;
diff --git a/link-hooks/t/src/properties.rs b/link-hooks/t/src/properties.rs
new file mode 100644
index 00000000..808f0b49
--- /dev/null
+++ b/link-hooks/t/src/properties.rs
@@ -0,0 +1,99 @@
// Copyright © 2022 The Radicle Link Contributors
// SPDX-License-Identifier: GPL-3.0-or-later

use proptest::prelude::*;

use link_hooks::{Data, IsZero, Track, Updated};
use radicle_git_ext as ext;
use test_helpers::roundtrip;

use crate::gen::{data::gen_data, track::gen_track};

proptest! {
    #[test]
    fn roundtrip_data(data in gen_data()) {
        roundtrip::str(data)
    }

    #[test]
    fn roundtrip_track(track in gen_track()) {
        roundtrip::str(track)
    }

    #[test]
    fn track_updated(track in gen_track()) {
        prop_track_created(track.clone());
        prop_track_deleted(track.clone());
        prop_track_changed(track);
    }

        #[test]
    fn data_updated(data in gen_data()) {
        prop_data_created(data.clone());
        prop_data_deleted(data.clone());
        prop_data_changed(data);
    }
}

fn prop_track_created(track: Track<ext::Oid>) {
    if !track.new.is_zero() {
        let track = Track {
            old: git2::Oid::zero().into(),
            ..track
        };

        assert_eq!(track.updated(), Updated::Created);
    }
}

fn prop_track_deleted(track: Track<ext::Oid>) {
    if !track.old.is_zero() {
        let track = Track {
            new: git2::Oid::zero().into(),
            ..track
        };

        assert_eq!(track.updated(), Updated::Deleted);
    }
}

fn prop_track_changed(track: Track<ext::Oid>) {
    if !track.old.is_zero() && !track.new.is_zero() {
        if track.old != track.new {
            assert_eq!(track.updated(), Updated::Changed);
        } else {
            assert_eq!(track.updated(), Updated::NoChange);
        }
    }
}

fn prop_data_created(data: Data<ext::Oid>) {
    if !data.new.is_zero() {
        let data = Data {
            old: git2::Oid::zero().into(),
            ..data
        };

        assert_eq!(data.updated(), Updated::Created);
    }
}

fn prop_data_deleted(data: Data<ext::Oid>) {
    if !data.old.is_zero() {
        let data = Data {
            new: git2::Oid::zero().into(),
            ..data
        };
        assert_eq!(data.updated(), Updated::Deleted);
    }
}

fn prop_data_changed(data: Data<ext::Oid>) {
    if !data.old.is_zero() && !data.new.is_zero() {
        if data.old != data.new {
            assert_eq!(data.updated(), Updated::Changed);
        } else {
            assert_eq!(data.updated(), Updated::NoChange);
        }
    }
}
diff --git a/test/Cargo.toml b/test/Cargo.toml
index 6ebc5100..c808347f 100644
--- a/test/Cargo.toml
+++ b/test/Cargo.toml
@@ -57,6 +57,10 @@ features = ["test"]
path = "../link-git/t"
features = ["test"]

[dev-dependencies.link-hooks-test]
path = "../link-hooks/t"
features = ["test"]

[dev-dependencies.link-identities-test]
path = "../link-identities/t"
features = ["test"]
-- 
2.31.1

[PATCH radicle-link v1 5/5] librad: wire up link-hooks Export this patch

Add `librad::git::hooks` to expose the `link-hooks` API through the
`hooks` constructor.

This handles the spawning of individual hooks under the `urn_changed`
and `tracking_changed` directories.

Signed-off-by: Fintan Halpenny <fintan.halpenny@gmail.com>
---
 bins/Cargo.lock         | 458 +++++++++++++++++++++-------------------
 librad/Cargo.toml       |   3 +
 librad/src/git.rs       |   1 +
 librad/src/git/hooks.rs |  66 ++++++
 4 files changed, 314 insertions(+), 214 deletions(-)
 create mode 100644 librad/src/git/hooks.rs

diff --git a/bins/Cargo.lock b/bins/Cargo.lock
index 4cb8dd64..cae3d94e 100644
--- a/bins/Cargo.lock
@@ -42,7 +42,7 @@ version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fcb51a0695d8f838b1ee009b3fbf66bda078cd64590202a864a8f3e8c4315c47"
dependencies = [
 "getrandom 0.2.5",
 "getrandom 0.2.6",
 "once_cell",
 "serde",
 "version_check",
@@ -68,9 +68,9 @@ dependencies = [

[[package]]
name = "anyhow"
version = "1.0.56"
version = "1.0.57"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4361135be9122e0870de935d7c439aef945b9f9ddd4199a553b5270b49c82a27"
checksum = "08f9b8508dccb7687a1d6c4ce66b2b0ecef467c94667de27d8d7fe1f8d2a9cdc"

[[package]]
name = "arc-swap"
@@ -122,9 +122,9 @@ dependencies = [

[[package]]
name = "async-io"
version = "1.6.0"
version = "1.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a811e6a479f2439f0c04038796b5cfb3d2ad56c230e0f2d3f7b04d68cfee607b"
checksum = "e5e18f61464ae81cde0a23e713ae8fd299580c54d697a35820cfd0625b8b0e07"
dependencies = [
 "concurrent-queue",
 "futures-lite",
@@ -150,9 +150,9 @@ dependencies = [

[[package]]
name = "async-process"
version = "1.3.0"
version = "1.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "83137067e3a2a6a06d67168e49e68a0957d215410473a740cea95a2425c0b7c6"
checksum = "cf2c06e30a24e8c78a3987d07f0930edf76ef35e027e7bdb063fccafdad1f60c"
dependencies = [
 "async-io",
 "blocking",
@@ -194,9 +194,9 @@ checksum = "30696a84d817107fc028e049980e09d5e140e8da8f1caeb17e8e950658a3cea9"

[[package]]
name = "async-trait"
version = "0.1.52"
version = "0.1.53"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "061a7acccaa286c011ddc30970520b98fa40e00c9d644633fb26b5fc63a265e3"
checksum = "ed6aa3524a2dfcf9fe180c51eae2b58738348d819517ceadf95789c51fff7600"
dependencies = [
 "proc-macro2",
 "quote",
@@ -271,7 +271,7 @@ version = "0.1.0"
source = "git+https://github.com/automerge/automerge-rs.git?rev=e72571962b51c2f0726fb534890ef3b4f7c74dfc#e72571962b51c2f0726fb534890ef3b4f7c74dfc"
dependencies = [
 "automerge-protocol",
 "getrandom 0.2.5",
 "getrandom 0.2.6",
 "maplit",
 "serde",
 "serde_json",
@@ -301,16 +301,16 @@ version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9fe17f59a06fe8b87a6fc8bf53bb70b3aba76d7685f432487a68cd5552853625"
dependencies = [
 "getrandom 0.2.5",
 "getrandom 0.2.6",
 "instant",
 "rand",
]

[[package]]
name = "base-x"
version = "0.2.8"
version = "0.2.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4521f3e3d031370679b3b140beb36dfe4801b09ac77e30c61941f97df3ef28b"
checksum = "dc19a4937b4fbd3fe3379793130e42060d10627a360f2127802b10b87e7baf74"

[[package]]
name = "base64"
@@ -320,9 +320,9 @@ checksum = "904dfeac50f3cdaba28fc6f57fdcddb75f49ed61346676a78c4ffe55877802fd"

[[package]]
name = "base64ct"
version = "1.4.1"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "71acf5509fc522cce1b100ac0121c635129bfd4d91cdf036bcc9b9935f97ccf5"
checksum = "dea908e7347a8c64e378c17e30ef880ad73e3b4498346b055c2c00ea342f3179"

[[package]]
name = "bcrypt-pbkdf"
@@ -568,16 +568,16 @@ dependencies = [

[[package]]
name = "clap"
version = "3.1.6"
version = "3.1.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d8c93436c21e4698bacadf42917db28b23017027a4deccb35dbe47a7e7840123"
checksum = "d2dbdf4bdacb33466e854ce889eee8dfd5729abf7ccd7664d0a2d60cd384440b"
dependencies = [
 "atty",
 "bitflags",
 "clap_derive",
 "clap_lex",
 "indexmap",
 "lazy_static",
 "os_str_bytes 6.0.0",
 "strsim",
 "termcolor",
 "textwrap",
@@ -585,9 +585,9 @@ dependencies = [

[[package]]
name = "clap_derive"
version = "3.1.4"
version = "3.1.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da95d038ede1a964ce99f49cbe27a7fb538d1da595e4b4f70b8c8f338d17bf16"
checksum = "25320346e922cffe59c0bbc5410c8d8784509efb321488971081313cb1e1a33c"
dependencies = [
 "heck 0.4.0",
 "proc-macro-error",
@@ -596,6 +596,15 @@ dependencies = [
 "syn",
]

[[package]]
name = "clap_lex"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a37c35f1112dad5e6e0b1adaff798507497a18fceeb30cceb3bae7d1427b9213"
dependencies = [
 "os_str_bytes 6.1.0",
]

[[package]]
name = "clru"
version = "0.5.0"
@@ -661,9 +670,9 @@ checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e"

[[package]]
name = "cpufeatures"
version = "0.2.1"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "95059428f66df56b63431fdb4e1947ed2190586af5c5a8a8b71122bdf5a7f469"
checksum = "59a6001667ab124aebae2a495118e11d30984c3a653e99d86d58971708cf5e4b"
dependencies = [
 "libc",
]
@@ -693,9 +702,9 @@ dependencies = [

[[package]]
name = "crossbeam-channel"
version = "0.5.2"
version = "0.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e54ea8bc3fb1ee042f5aace6e3c6e025d3874866da222930f70ce62aceba0bfa"
checksum = "5aaa7bd5fb665c6864b5f963dd9097905c54125909c7aa94c9e18507cdbe6c53"
dependencies = [
 "cfg-if 1.0.0",
 "crossbeam-utils",
@@ -714,10 +723,11 @@ dependencies = [

[[package]]
name = "crossbeam-epoch"
version = "0.9.7"
version = "0.9.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c00d6d2ea26e8b151d99093005cb442fb9a37aeaca582a03ec70946f49ab5ed9"
checksum = "1145cf131a2c6ba0615079ab6a638f7e1973ac9c2634fcbeaaad6114246efe8c"
dependencies = [
 "autocfg",
 "cfg-if 1.0.0",
 "crossbeam-utils",
 "lazy_static",
@@ -727,9 +737,9 @@ dependencies = [

[[package]]
name = "crossbeam-queue"
version = "0.3.4"
version = "0.3.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4dd435b205a4842da59efd07628f921c096bc1cc0a156835b4fa0bcb9a19bcce"
checksum = "1f25d8400f4a7a5778f0e4e52384a48cbd9b5c495d110786187fc750075277a2"
dependencies = [
 "cfg-if 1.0.0",
 "crossbeam-utils",
@@ -737,9 +747,9 @@ dependencies = [

[[package]]
name = "crossbeam-utils"
version = "0.8.7"
version = "0.8.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b5e5bed1f1c269533fa816a0a5492b3545209a205ca1a54842be180eb63a16a6"
checksum = "0bf124c720b7686e3c2663cf54062ab0f68a88af2fb6a030e87e30bf721fcb38"
dependencies = [
 "cfg-if 1.0.0",
 "lazy_static",
@@ -766,9 +776,9 @@ dependencies = [

[[package]]
name = "curve25519-dalek"
version = "3.2.0"
version = "3.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b9fdf9972b2bd6af2d913799d9ebc165ea4d2e65878e329d9c6b372c4491b61"
checksum = "90f9d052967f590a76e62eb387bd0bbb1b000182c3cefe5364db6b7211651bc0"
dependencies = [
 "byteorder",
 "digest",
@@ -789,13 +799,14 @@ dependencies = [

[[package]]
name = "dashmap"
version = "5.1.0"
version = "5.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c0834a35a3fce649144119e18da2a4d8ed12ef3862f47183fd46f625d072d96c"
checksum = "3495912c9c1ccf2e18976439f4443f3fee0fd61f424ff99fde6a66b15ecb448f"
dependencies = [
 "cfg-if 1.0.0",
 "num_cpus",
 "parking_lot 0.12.0",
 "hashbrown 0.12.1",
 "lock_api",
 "parking_lot_core 0.9.3",
]

[[package]]
@@ -874,9 +885,9 @@ dependencies = [

[[package]]
name = "dirs-sys"
version = "0.3.6"
version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "03d86534ed367a67548dc68113a0f5db55432fdfbb6e6f9d77704397d95d5780"
checksum = "1b1d1d91c932ef41c0f2663aa8b0ca0342d444d842c06914aa0a7e352d0bada6"
dependencies = [
 "libc",
 "redox_users",
@@ -885,9 +896,9 @@ dependencies = [

[[package]]
name = "dyn-clone"
version = "1.0.4"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee2626afccd7561a06cf1367e2950c4718ea04565e20fb5029b6c7d8ad09abcf"
checksum = "21e50f3adc76d6a43f5ed73b698a87d0760ca74617f60f7c3b879003536fdd28"

[[package]]
name = "ed25519-zebra"
@@ -963,9 +974,9 @@ dependencies = [

[[package]]
name = "filetime"
version = "0.2.15"
version = "0.2.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "975ccf83d8d9d0d84682850a38c8169027be83368805971cc4f238c2b245bc98"
checksum = "c0408e2626025178a6a7f7ffc05a25bc47103229f19c113755de7bf63816290c"
dependencies = [
 "cfg-if 1.0.0",
 "libc",
@@ -981,13 +992,11 @@ checksum = "37ab347416e802de484e4d03c7316c48f1ecb56574dfd4a46a80f173ce1de04d"

[[package]]
name = "flate2"
version = "1.0.22"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e6988e897c1c9c485f43b47a529cef42fde0547f9d8d41a7062518f1d8fc53f"
checksum = "f82b0f4c27ad9f8bfd1f3208d882da2b09c301bc1c828fd3a00d0216d2fbbff6"
dependencies = [
 "cfg-if 1.0.0",
 "crc32fast",
 "libc",
 "libz-sys",
 "miniz_oxide",
]
@@ -1208,9 +1217,9 @@ dependencies = [

[[package]]
name = "getrandom"
version = "0.2.5"
version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d39cd93900197114fa1fcb7ae84ca742095eed9442088988ae74fa744e930e77"
checksum = "9be70c98951c83b8d2f8f60d7065fa6d5146873094452a1008da8c2f1e4205ad"
dependencies = [
 "cfg-if 1.0.0",
 "js-sys",
@@ -1341,9 +1350,9 @@ dependencies = [

[[package]]
name = "git-packetline"
version = "0.12.3"
version = "0.12.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6f56074b167ca1dc87f5f5f81dcd812376d774c235e5fa3a4bf6e91287496aea"
checksum = "c42acbfb60871e96ed09f8c0730020aa4ccfa6ab6bcbb2b64a5150bfc7053773"
dependencies = [
 "bstr",
 "futures-io",
@@ -1425,7 +1434,7 @@ version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf6d78c8a68ab3ba1cd42684dbe0233e81a28301321c4a66417285e47cd79c6b"
dependencies = [
 "dashmap 5.1.0",
 "dashmap 5.3.4",
 "libc",
 "once_cell",
 "signal-hook",
@@ -1484,9 +1493,9 @@ dependencies = [

[[package]]
name = "git-validate"
version = "0.5.3"
version = "0.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c58bafc6cd6f455a95997c47823182dd62cdb47f03564c44c6b6d910221801dc"
checksum = "2c2c1eaaa942eb3c49ab20f27c0715e79e26e6b156a0f77d7ed7bbb26126a8fa"
dependencies = [
 "bstr",
 "quick-error",
@@ -1593,6 +1602,12 @@ dependencies = [
 "ahash 0.7.6",
]

[[package]]
name = "hashbrown"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db0d4cf898abf0081f964436dc980e96670a0f36863e4b83aaacdb65c9d7ccc3"

[[package]]
name = "heck"
version = "0.3.3"
@@ -1704,12 +1719,12 @@ dependencies = [

[[package]]
name = "im"
version = "15.0.0"
version = "15.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "111c1983f3c5bb72732df25cddacee9b546d08325fb584b5ebd38148be7b0246"
checksum = "d0acd33ff0285af998aaf9b57342af478078f53492322fafc47450e09397e0e9"
dependencies = [
 "bitmaps",
 "rand_core 0.5.1",
 "rand_core 0.6.3",
 "rand_xoshiro",
 "sized-chunks",
 "typenum",
@@ -1718,9 +1733,9 @@ dependencies = [

[[package]]
name = "indexmap"
version = "1.8.0"
version = "1.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "282a6247722caba404c065016bbfa522806e51714c34f5dfc3e4a3a46fcb4223"
checksum = "e6012d540c5baa3589337a98ce73408de9b5a25ec9fc2c6fd6be8f0d39e0ca5a"
dependencies = [
 "autocfg",
 "hashbrown 0.11.2",
@@ -1766,9 +1781,9 @@ dependencies = [

[[package]]
name = "ipnet"
version = "2.4.0"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "35e70ee094dc02fd9c13fdad4940090f22dbd6ac7c9e7094a46cf0232a50bc7c"
checksum = "879d54834c8c76457ef4293a689b2a8c59b076067ad77b15efafbb05f92a592b"

[[package]]
name = "iso8601"
@@ -1805,9 +1820,9 @@ checksum = "b71991ff56294aa922b450139ee08b3bfc70982c6b2c7562771375cf73542dd4"

[[package]]
name = "itoa"
version = "1.0.1"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1aab8fc367588b89dcee83ab0fd66b72b50b72fa1904d7095045ace2b0c81c35"
checksum = "112c678d4050afce233f4f2852bb2eb519230b3cf12f33585275537d7e41578d"

[[package]]
name = "jobserver"
@@ -1820,9 +1835,9 @@ dependencies = [

[[package]]
name = "js-sys"
version = "0.3.56"
version = "0.3.57"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a38fc24e30fd564ce974c02bf1d337caddff65be6cc4735a1f7eab22a7440f04"
checksum = "671a26f820db17c2a2750743f1dd03bafd15b98c9f30c7c2628c024c05d73397"
dependencies = [
 "wasm-bindgen",
]
@@ -1842,7 +1857,7 @@ dependencies = [
 "itoa 0.4.8",
 "lazy_static",
 "num-cmp",
 "parking_lot 0.12.0",
 "parking_lot 0.12.1",
 "percent-encoding",
 "regex",
 "serde",
@@ -1864,9 +1879,9 @@ dependencies = [

[[package]]
name = "keccak"
version = "0.1.0"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67c21572b4949434e4fc1e1978b99c5f77064153c59d998bf13ecd96fb5ecba7"
checksum = "f9b7d56ba4a8344d6be9729995e6b06f928af29998cdf79fe390cbf6b1fee838"

[[package]]
name = "kernel32-sys"
@@ -1898,9 +1913,9 @@ checksum = "884e2677b40cc8c339eaefcb701c32ef1fd2493d71118dc0ca4b6a736c93bd67"

[[package]]
name = "libc"
version = "0.2.119"
version = "0.2.126"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1bf2e165bb3457c8e098ea76f3e3bc9db55f87aa90d52d0e6be741470916aaa4"
checksum = "349d5a591cd28b49e1d1037471617a32ddcda5731b99419008085f72d5a53836"

[[package]]
name = "libgit2-sys"
@@ -1948,6 +1963,7 @@ dependencies = [
 "link-canonical",
 "link-crypto",
 "link-git",
 "link-hooks",
 "link-identities",
 "link-replication",
 "link-tracking",
@@ -1960,7 +1976,7 @@ dependencies = [
 "notify",
 "num_cpus",
 "once_cell",
 "parking_lot 0.12.0",
 "parking_lot 0.12.1",
 "percent-encoding",
 "picky-asn1",
 "picky-asn1-der",
@@ -2008,9 +2024,9 @@ dependencies = [

[[package]]
name = "libz-sys"
version = "1.1.5"
version = "1.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6f35facd4a5673cb5a48822be2be1d4236c1c99cb4113cab7061ac720d5bf859"
checksum = "9702761c3935f8cc2f101793272e202c72b99da8f4224a19ddcf1279a6450bbf"
dependencies = [
 "cc",
 "cmake",
@@ -2104,7 +2120,7 @@ dependencies = [
 "im",
 "lazy_static",
 "once_cell",
 "parking_lot 0.12.0",
 "parking_lot 0.12.1",
 "pin-project 1.0.10",
 "regex",
 "rustc-hash",
@@ -2114,6 +2130,20 @@ dependencies = [
 "versions",
]

[[package]]
name = "link-hooks"
version = "0.1.0"
dependencies = [
 "async-trait",
 "futures",
 "link-crypto",
 "link-identities",
 "multihash",
 "thiserror",
 "tokio",
 "tracing",
]

[[package]]
name = "link-identities"
version = "0.1.0"
@@ -2157,7 +2187,7 @@ dependencies = [
 "itertools 0.10.3",
 "link-crypto",
 "link-git",
 "parking_lot 0.12.0",
 "parking_lot 0.12.1",
 "radicle-data",
 "radicle-std-ext",
 "rand",
@@ -2461,27 +2491,28 @@ dependencies = [

[[package]]
name = "lock_api"
version = "0.4.6"
version = "0.4.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "88943dd7ef4a2e5a4bfa2753aaab3013e34ce2533d1996fb18ef591e315e2b3b"
checksum = "327fa5b6a6940e4699ec49a9beae1ea4845c6bab9314e4f84ac68742139d8c53"
dependencies = [
 "autocfg",
 "scopeguard",
]

[[package]]
name = "log"
version = "0.4.14"
version = "0.4.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "51b9bbe6c47d51fc3e1a9b945965946b4c44142ab8792c50835a980d362c2710"
checksum = "abb12e687cfb44aa40f41fc3978ef76448f9b6038cad6aef4259d3c095a2382e"
dependencies = [
 "cfg-if 1.0.0",
]

[[package]]
name = "lru"
version = "0.7.3"
version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fcb87f3080f6d1d69e8c564c0fcfde1d7aa8cc451ce40cae89479111f03bc0eb"
checksum = "8015d95cb7b2ddd3c0d32ca38283ceb1eea09b4713ee380bceb942d85a244228"
dependencies = [
 "hashbrown 0.11.2",
]
@@ -2526,9 +2557,9 @@ checksum = "490cc448043f947bae3cbee9c203358d62dbee0db12107a74be5c30ccfd09771"

[[package]]
name = "memchr"
version = "2.4.1"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "308cc39be01b73d0d18f82a0e7b2a3df85245f84af96fdddc5d202d27e47b86a"
checksum = "2dffe52ecf27772e601905b7522cb4ef790d2cc203488bbd0e2fe85fcb74566d"

[[package]]
name = "memoffset"
@@ -2567,12 +2598,11 @@ checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"

[[package]]
name = "miniz_oxide"
version = "0.4.4"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a92518e98c078586bc6c934028adcca4c92a53d6a958196de835170a01d84e4b"
checksum = "6f5c75688da582b8ffc1f1799e9db273f32133c49e048f614d22ec3256773ccc"
dependencies = [
 "adler",
 "autocfg",
]

[[package]]
@@ -2609,16 +2639,14 @@ dependencies = [

[[package]]
name = "mio"
version = "0.8.1"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7ba42135c6a5917b9db9cd7b293e5409e1c6b041e6f9825e92e55a894c63b6f8"
checksum = "713d550d9b44d89174e066b7a6217ae06234c10cb47819a88290d2b353c31799"
dependencies = [
 "libc",
 "log",
 "miow 0.3.7",
 "ntapi",
 "wasi 0.11.0+wasi-snapshot-preview1",
 "winapi 0.3.9",
 "windows-sys",
]

[[package]]
@@ -2715,13 +2743,12 @@ dependencies = [

[[package]]
name = "nom"
version = "7.1.0"
version = "7.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b1d11e1ef389c76fe5b81bcaf2ea32cf88b62bc494e19f493d0b30e7a930109"
checksum = "a8903e5a29a317527874d0402f867152a3d21c908bb0b933e416c65e301d4c36"
dependencies = [
 "memchr",
 "minimal-lexical",
 "version_check",
]

[[package]]
@@ -2823,9 +2850,9 @@ dependencies = [

[[package]]
name = "num-integer"
version = "0.1.44"
version = "0.1.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2cc698a63b549a70bc047073d2949cce27cd1c7b0a4a862d08a8031bc2801db"
checksum = "225d3389fb3509a24c93f5c29eb6bde2586b98d9f016636dff58d7c6f7569cd9"
dependencies = [
 "autocfg",
 "num-traits",
@@ -2833,9 +2860,9 @@ dependencies = [

[[package]]
name = "num-iter"
version = "0.1.42"
version = "0.1.43"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b2021c8337a54d21aca0d59a92577a029af9431cb59b909b03252b9c164fad59"
checksum = "7d03e6c028c5dc5cac6e2dec0efda81fc887605bb3d884578bb6d6bf7514e252"
dependencies = [
 "autocfg",
 "num-integer",
@@ -2856,9 +2883,9 @@ dependencies = [

[[package]]
name = "num-traits"
version = "0.2.14"
version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a64b1ec5cda2586e284722486d802acf1f7dbdc623e2bfc57e65ca1cd099290"
checksum = "578ede34cf02f8924ab9447f50c28075b4d3e5b269972345e7e0372b38c6cdcd"
dependencies = [
 "autocfg",
]
@@ -2875,9 +2902,9 @@ dependencies = [

[[package]]
name = "num_threads"
version = "0.1.4"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c539a50b93a303167eded6e8dff5220cd39447409fb659f4cd24b1f72fe4f133"
checksum = "2819ce041d2ee131036f4fc9d6ae7ae125a3a40e97ba64d04fe799ad9dabbb44"
dependencies = [
 "libc",
]
@@ -2893,9 +2920,9 @@ dependencies = [

[[package]]
name = "once_cell"
version = "1.10.0"
version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "87f3e037eac156d1775da914196f0f37741a274155e34a0b7e427c35d2a2ecb9"
checksum = "7709cef83f0c1f58f666e746a08b21e0085f7440fa6a29cc194d68aac97a4225"

[[package]]
name = "opaque-debug"
@@ -2911,12 +2938,9 @@ checksum = "6acbef58a60fe69ab50510a55bc8cdd4d6cf2283d27ad338f54cb52747a9cf2d"

[[package]]
name = "os_str_bytes"
version = "6.0.0"
version = "6.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e22443d1643a904602595ba1cd8f7d896afe56d26712531c5ff73a15b2fbf64"
dependencies = [
 "memchr",
]
checksum = "21326818e99cfe6ce1e524c2a805c189a99b5ae555a35d19f9a284b427d86afa"

[[package]]
name = "parking"
@@ -2937,12 +2961,12 @@ dependencies = [

[[package]]
name = "parking_lot"
version = "0.12.0"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "87f5ec2493a61ac0506c0f4199f99070cbe83857b0337006a30f3e6719b8ef58"
checksum = "3742b2c103b9f06bc9fff0a37ff4912935851bee6d36f3c02bcc755bcfec228f"
dependencies = [
 "lock_api",
 "parking_lot_core 0.9.1",
 "parking_lot_core 0.9.3",
]

[[package]]
@@ -2961,9 +2985,9 @@ dependencies = [

[[package]]
name = "parking_lot_core"
version = "0.9.1"
version = "0.9.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "28141e0cc4143da2443301914478dc976a61ffdb3f043058310c70df2fed8954"
checksum = "09a279cbf25cb0757810394fbc1e359949b59e348145c643a939a525692e6929"
dependencies = [
 "cfg-if 1.0.0",
 "libc",
@@ -3098,9 +3122,9 @@ dependencies = [

[[package]]
name = "pin-project-lite"
version = "0.2.8"
version = "0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e280fbe77cc62c91527259e9442153f4688736748d24660126286329742b4c6c"
checksum = "e0a7ae3ac2f1173085d398531c705756c94a4c56843785df85a60c1a0afac116"

[[package]]
name = "pin-utils"
@@ -3110,9 +3134,9 @@ checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"

[[package]]
name = "pkg-config"
version = "0.3.24"
version = "0.3.25"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "58893f751c9b0412871a09abd62ecd2a00298c6c83befa223ef98c52aef40cbe"
checksum = "1df8c4ec4b0627e53bdf214615ad287367e482558cf84b109250b37464dc03ae"

[[package]]
name = "polling"
@@ -3170,11 +3194,11 @@ dependencies = [

[[package]]
name = "proc-macro2"
version = "1.0.36"
version = "1.0.39"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c7342d5883fbccae1cc37a2353b09c87c9b0f3afd73f5fb9bba687a1f733b029"
checksum = "c54b25569025b7fc9651de43004ae593a75ad88543b17178aa5e1b9c4f15f56f"
dependencies = [
 "unicode-xid",
 "unicode-ident",
]

[[package]]
@@ -3242,9 +3266,9 @@ dependencies = [

[[package]]
name = "quote"
version = "1.0.15"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "864d3e96a899863136fc6e99f3d7cae289dafe43bf2c5ac19b70df7210c0a145"
checksum = "a1feb54ed693b93a84e14094943b84b7c4eae204c512b7ccb95ab0c66d278ad1"
dependencies = [
 "proc-macro2",
]
@@ -3350,7 +3374,7 @@ version = "0.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d34f1408f55294453790c48b2f1ebbb1c5b4b7563eb1f418bcfcfdbb06ebb4e7"
dependencies = [
 "getrandom 0.2.5",
 "getrandom 0.2.6",
]

[[package]]
@@ -3364,18 +3388,18 @@ dependencies = [

[[package]]
name = "rand_xoshiro"
version = "0.4.0"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a9fcdd2e881d02f1d9390ae47ad8e5696a9e4be7b547a1da2afbc61973217004"
checksum = "6f97cdb2a36ed4183de61b2f824cc45c9f1037f28afe0a322e9fff4c108b5aaa"
dependencies = [
 "rand_core 0.5.1",
 "rand_core 0.6.3",
]

[[package]]
name = "rayon"
version = "1.5.1"
version = "1.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c06aca804d41dbc8ba42dfd964f0d01334eceb64314b9ecf7c5fad5188a06d90"
checksum = "bd99e5772ead8baa5215278c9b15bf92087709e9c1b2d1f97cdb5a183c933a7d"
dependencies = [
 "autocfg",
 "crossbeam-deque",
@@ -3385,41 +3409,41 @@ dependencies = [

[[package]]
name = "rayon-core"
version = "1.9.1"
version = "1.9.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d78120e2c850279833f1dd3582f730c4ab53ed95aeaaaa862a2a5c71b1656d8e"
checksum = "258bcdb5ac6dad48491bb2992db6b7cf74878b0384908af124823d118c99683f"
dependencies = [
 "crossbeam-channel",
 "crossbeam-deque",
 "crossbeam-utils",
 "lazy_static",
 "num_cpus",
]

[[package]]
name = "redox_syscall"
version = "0.2.11"
version = "0.2.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8380fe0152551244f0747b1bf41737e0f8a74f97a14ccefd1148187271634f3c"
checksum = "62f25bc4c7e55e0b0b7a1d43fb893f4fa1361d0abe38b9ce4f323c2adfe6ef42"
dependencies = [
 "bitflags",
]

[[package]]
name = "redox_users"
version = "0.4.0"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "528532f3d801c87aec9def2add9ca802fe569e44a544afe633765267840abe64"
checksum = "b033d837a7cf162d7993aded9304e30a83213c648b6e389db233191f891e5c2b"
dependencies = [
 "getrandom 0.2.5",
 "getrandom 0.2.6",
 "redox_syscall",
 "thiserror",
]

[[package]]
name = "regex"
version = "1.5.5"
version = "1.5.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1a11647b6b25ff05a515cb92c365cec08801e83423a235b51e231e1808747286"
checksum = "d83f127d94bdbcda4c8cc2e50f6f84f4b611f69c902699ca385a39c3a75f9ff1"
dependencies = [
 "aho-corasick",
 "memchr",
@@ -3437,9 +3461,9 @@ dependencies = [

[[package]]
name = "regex-syntax"
version = "0.6.25"
version = "0.6.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f497285884f3fcff424ffc933e56d7cbca511def0c9831a7f9b5f6153e3cc89b"
checksum = "49b3de9ec5dc0a3417da371aab17d729997c15010e7fd24ff707773a33bddb64"

[[package]]
name = "remove_dir_all"
@@ -3496,9 +3520,9 @@ dependencies = [

[[package]]
name = "ryu"
version = "1.0.9"
version = "1.0.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73b4b750c782965c211b42f022f59af1fbceabdd026623714f104152f1ec149f"
checksum = "f3f6f92acf49d1b98f7a81226834412ada05458b7364277387724a237f062695"

[[package]]
name = "salsa20"
@@ -3557,18 +3581,18 @@ dependencies = [

[[package]]
name = "serde"
version = "1.0.136"
version = "1.0.137"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce31e24b01e1e524df96f1c2fdd054405f8d7376249a5110886fb4b658484789"
checksum = "61ea8d54c77f8315140a05f4c7237403bf38b72704d031543aa1d16abbf517d1"
dependencies = [
 "serde_derive",
]

[[package]]
name = "serde_bytes"
version = "0.11.5"
version = "0.11.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "16ae07dd2f88a366f15bd0632ba725227018c69a1c8550a927324f8eb8368bb9"
checksum = "212e73464ebcde48d723aa02eb270ba62eff38a9b732df31f33f1b4e145f3a54"
dependencies = [
 "serde",
]
@@ -3586,9 +3610,9 @@ dependencies = [

[[package]]
name = "serde_derive"
version = "1.0.136"
version = "1.0.137"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08597e7152fcd306f41838ed3e37be9eaeed2b61c42e2117266a554fab4662f9"
checksum = "1f26faba0c3959972377d3b2d306ee9f71faee9714294e41bb777f83f88578be"
dependencies = [
 "proc-macro2",
 "quote",
@@ -3597,11 +3621,11 @@ dependencies = [

[[package]]
name = "serde_json"
version = "1.0.79"
version = "1.0.81"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e8d9fa5c3b304765ce1fd9c4c8a3de2c8db365a5b91be52f186efc675681d95"
checksum = "9b7ce2b32a1aed03c558dc61a5cd328f15aff2dbc17daad8fb8af04d2100e15c"
dependencies = [
 "itoa 1.0.1",
 "itoa 1.0.2",
 "ryu",
 "serde",
]
@@ -3670,9 +3694,9 @@ dependencies = [

[[package]]
name = "signal-hook"
version = "0.3.13"
version = "0.3.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "647c97df271007dcea485bb74ffdb57f2e683f1306c854f468a0c244badabf2d"
checksum = "a253b5e89e2698464fc26b545c9edceb338e18a89effeeecfea192c3025be29d"
dependencies = [
 "libc",
 "signal-hook-registry",
@@ -3714,9 +3738,9 @@ dependencies = [

[[package]]
name = "slab"
version = "0.4.5"
version = "0.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9def91fd1e018fe007022791f865d0ccc9b3a0d5001e01aabb8b40e46000afb5"
checksum = "eb703cfe953bccee95685111adeedb76fabe4e97549a58d16f03ea7b9367bb32"

[[package]]
name = "smallvec"
@@ -3726,9 +3750,9 @@ checksum = "f2dd574626839106c320a323308629dcb1acfc96e32a8cba364ddc61ac23ee83"

[[package]]
name = "smol_str"
version = "0.1.21"
version = "0.1.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "61d15c83e300cce35b7c8cd39ff567c1ef42dde6d4a1a38dbdbf9a59902261bd"
checksum = "7475118a28b7e3a2e157ce0131ba8c5526ea96e90ee601d9f6bb2e286a35ab44"
dependencies = [
 "serde",
]
@@ -3795,13 +3819,13 @@ checksum = "6bdef32e8150c2a081110b42772ffe7d7c9032b606bc226c8260fd97e0976601"

[[package]]
name = "syn"
version = "1.0.87"
version = "1.0.95"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e59d925cf59d8151f25a3bedf97c9c157597c9df7324d32d68991cc399ed08b"
checksum = "fbaf6116ab8924f39d52792136fb74fd60a80194cf1b1c6ffa6453eef1c3f942"
dependencies = [
 "proc-macro2",
 "quote",
 "unicode-xid",
 "unicode-ident",
]

[[package]]
@@ -3847,18 +3871,18 @@ checksum = "b1141d4d61095b28419e22cb0bbf02755f5e54e0526f97f1e3d1d160e60885fb"

[[package]]
name = "thiserror"
version = "1.0.30"
version = "1.0.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "854babe52e4df1653706b98fcfc05843010039b406875930a70e4d9644e5c417"
checksum = "bd829fe32373d27f76265620b5309d0340cb8550f523c1dda251d6298069069a"
dependencies = [
 "thiserror-impl",
]

[[package]]
name = "thiserror-impl"
version = "1.0.30"
version = "1.0.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aa32fd3f627f367fe16f893e2597ae3c05020f8bba2666a4e6ea73d377e5714b"
checksum = "0396bc89e626244658bef819e22d0cc459e795a5ebe878e6ec336d1674a8d79a"
dependencies = [
 "proc-macro2",
 "quote",
@@ -3876,9 +3900,9 @@ dependencies = [

[[package]]
name = "time"
version = "0.3.7"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "004cbc98f30fa233c61a38bc77e96a9106e65c88f2d3bef182ae952027e5753d"
checksum = "c2702e08a7a860f005826c6815dcac101b19b5eb330c27fe4a5928fec1d20ddd"
dependencies = [
 "libc",
 "num_threads",
@@ -3887,15 +3911,15 @@ dependencies = [

[[package]]
name = "time-macros"
version = "0.2.3"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "25eb0ca3468fc0acc11828786797f6ef9aa1555e4a211a60d64cc8e4d1be47d6"
checksum = "42657b1a6f4d817cda8e7a0ace261fe0cc946cf3a80314390b22cc61ae080792"

[[package]]
name = "tinyvec"
version = "1.5.1"
version = "1.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2c1c1d5a42b6245520c249549ec267180beaffcc0615401ac8e31853d4b6d8d2"
checksum = "87cc5ceb3875bb20c2890005a4e226a4651264a5c75edb2421b52861a0a0cb50"
dependencies = [
 "tinyvec_macros",
]
@@ -3908,14 +3932,14 @@ checksum = "cda74da7e1a664f795bb1f8a87ec406fb89a02522cf6e50620d016add6dbbf5c"

[[package]]
name = "tokio"
version = "1.17.0"
version = "1.18.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2af73ac49756f3f7c01172e34a23e5d0216f6c32333757c2c61feb2bbff5a5ee"
checksum = "4903bf0427cf68dddd5aa6a93220756f8be0c34fcfa9f5e6191e103e15a31395"
dependencies = [
 "bytes 1.1.0",
 "libc",
 "memchr",
 "mio 0.8.1",
 "mio 0.8.3",
 "num_cpus",
 "once_cell",
 "pin-project-lite",
@@ -3949,18 +3973,18 @@ dependencies = [

[[package]]
name = "toml"
version = "0.5.8"
version = "0.5.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a31142970826733df8241ef35dc040ef98c679ab14d7c3e54d827099b3acecaa"
checksum = "8d82e1a7758622a465f8cee077614c73484dac5b836c02ff6a40d5d1010324d7"
dependencies = [
 "serde",
]

[[package]]
name = "tracing"
version = "0.1.32"
version = "0.1.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4a1bdf54a7c28a2bbf701e1d2233f6c77f473486b94bee4f9678da5a148dca7f"
checksum = "5d0ecdcb44a79f0fe9844f0c4f33a342cbcbb5117de8001e6ba0dc2351327d09"
dependencies = [
 "cfg-if 1.0.0",
 "log",
@@ -3971,9 +3995,9 @@ dependencies = [

[[package]]
name = "tracing-attributes"
version = "0.1.20"
version = "0.1.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2e65ce065b4b5c53e73bb28912318cb8c9e9ad3921f1d669eb0e68b4c8143a2b"
checksum = "cc6b8ad3567499f98a1db7a752b07a7c8c7c7c34c332ec00effb2b0027974b7c"
dependencies = [
 "proc-macro2",
 "quote",
@@ -3982,9 +4006,9 @@ dependencies = [

[[package]]
name = "tracing-core"
version = "0.1.23"
version = "0.1.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aa31669fa42c09c34d94d8165dd2012e8ff3c66aca50f3bb226b68f216f2706c"
checksum = "f54c8ca710e81886d498c2fd3331b56c93aa248d49de2222ad2742247c60072f"
dependencies = [
 "lazy_static",
 "valuable",
@@ -3992,9 +4016,9 @@ dependencies = [

[[package]]
name = "tracing-log"
version = "0.1.2"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6923477a48e41c1951f1999ef8bb5a3023eb723ceadafe78ffb65dc366761e3"
checksum = "78ddad33d2d10b1ed7eb9d1f518a5674713876e97e5bb9b7345a7984fbb4f922"
dependencies = [
 "lazy_static",
 "log",
@@ -4013,9 +4037,9 @@ dependencies = [

[[package]]
name = "tracing-subscriber"
version = "0.3.9"
version = "0.3.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e0ab7bdc962035a87fba73f3acca9b8a8d0034c2e6f60b84aeaaddddc155dce"
checksum = "4bc28f93baff38037f64e6f43d34cfa1605f27a49c34e8a04c5e78b0babf2596"
dependencies = [
 "ansi_term",
 "lazy_static",
@@ -4049,9 +4073,15 @@ dependencies = [

[[package]]
name = "unicode-bidi"
version = "0.3.7"
version = "0.3.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1a01404663e3db436ed2746d9fefef640d868edae3cceb81c3b8d5732fda678f"
checksum = "099b7128301d285f79ddd55b9a83d5e6b9e97c92e0ea0daebee7263e932de992"

[[package]]
name = "unicode-ident"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d22af068fba1eb5edcb4aea19d382b2a3deb4c8f9d475c589b6ada9e0fd493ee"

[[package]]
name = "unicode-normalization"
@@ -4070,9 +4100,9 @@ checksum = "7e8820f5d777f6224dc4be3632222971ac30164d4a258d595640799554ebfd99"

[[package]]
name = "unicode-xid"
version = "0.2.2"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ccb82d61f80a663efe1f787a51b16b5a51e3314d6ac365b08639f52387b33f3"
checksum = "957e51f3646910546462e67d5f7599b9e4fb8acdd304b087a6494730f9eebf04"

[[package]]
name = "universal-hash"
@@ -4115,7 +4145,7 @@ version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bc5cf98d8186244414c848017f0e2676b3fcb46807f6668a97dfe67359a3c4b7"
dependencies = [
 "getrandom 0.2.5",
 "getrandom 0.2.6",
 "serde",
]

@@ -4184,9 +4214,9 @@ checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423"

[[package]]
name = "wasm-bindgen"
version = "0.2.79"
version = "0.2.80"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "25f1af7423d8588a3d840681122e72e6a24ddbcb3f0ec385cac0d12d24256c06"
checksum = "27370197c907c55e3f1a9fbe26f44e937fe6451368324e009cba39e139dc08ad"
dependencies = [
 "cfg-if 1.0.0",
 "wasm-bindgen-macro",
@@ -4194,9 +4224,9 @@ dependencies = [

[[package]]
name = "wasm-bindgen-backend"
version = "0.2.79"
version = "0.2.80"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b21c0df030f5a177f3cba22e9bc4322695ec43e7257d865302900290bcdedca"
checksum = "53e04185bfa3a779273da532f5025e33398409573f348985af9a1cbf3774d3f4"
dependencies = [
 "bumpalo",
 "lazy_static",
@@ -4209,9 +4239,9 @@ dependencies = [

[[package]]
name = "wasm-bindgen-macro"
version = "0.2.79"
version = "0.2.80"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f4203d69e40a52ee523b2529a773d5ffc1dc0071801c87b3d270b471b80ed01"
checksum = "17cae7ff784d7e83a2fe7611cfe766ecf034111b49deb850a3dc7699c08251f5"
dependencies = [
 "quote",
 "wasm-bindgen-macro-support",
@@ -4219,9 +4249,9 @@ dependencies = [

[[package]]
name = "wasm-bindgen-macro-support"
version = "0.2.79"
version = "0.2.80"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bfa8a30d46208db204854cadbb5d4baf5fcf8071ba5bf48190c3e59937962ebc"
checksum = "99ec0dc7a4756fffc231aab1b9f2f578d23cd391390ab27f952ae0c9b3ece20b"
dependencies = [
 "proc-macro2",
 "quote",
@@ -4232,15 +4262,15 @@ dependencies = [

[[package]]
name = "wasm-bindgen-shared"
version = "0.2.79"
version = "0.2.80"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d958d035c4438e28c70e4321a2911302f10135ce78a9c7834c0cab4123d06a2"
checksum = "d554b7f530dee5964d9a9468d95c1f8b8acae4f282807e7d27d4b03099a46744"

[[package]]
name = "web-sys"
version = "0.3.56"
version = "0.3.57"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c060b319f29dd25724f09a2ba1418f142f539b2be99fbf4d2d5a8f7330afb8eb"
checksum = "7b17e741662c70c8bd24ac5c5b18de314a2c26c32bf8346ee1e6f53de919c283"
dependencies = [
 "js-sys",
 "wasm-bindgen",
@@ -4310,9 +4340,9 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"

[[package]]
name = "windows-sys"
version = "0.32.0"
version = "0.36.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3df6e476185f92a12c072be4a189a0210dcdcf512a1891d6dff9edb874deadc6"
checksum = "ea04155a16a59f9eab786fe12a4a450e75cdb175f9e0d80da1e17db09f55b8d2"
dependencies = [
 "windows_aarch64_msvc",
 "windows_i686_gnu",
@@ -4323,33 +4353,33 @@ dependencies = [

[[package]]
name = "windows_aarch64_msvc"
version = "0.32.0"
version = "0.36.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d8e92753b1c443191654ec532f14c199742964a061be25d77d7a96f09db20bf5"
checksum = "9bb8c3fd39ade2d67e9874ac4f3db21f0d710bee00fe7cab16949ec184eeaa47"

[[package]]
name = "windows_i686_gnu"
version = "0.32.0"
version = "0.36.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6a711c68811799e017b6038e0922cb27a5e2f43a2ddb609fe0b6f3eeda9de615"
checksum = "180e6ccf01daf4c426b846dfc66db1fc518f074baa793aa7d9b9aaeffad6a3b6"

[[package]]
name = "windows_i686_msvc"
version = "0.32.0"
version = "0.36.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "146c11bb1a02615db74680b32a68e2d61f553cc24c4eb5b4ca10311740e44172"
checksum = "e2e7917148b2812d1eeafaeb22a97e4813dfa60a3f8f78ebe204bcc88f12f024"

[[package]]
name = "windows_x86_64_gnu"
version = "0.32.0"
version = "0.36.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c912b12f7454c6620635bbff3450962753834be2a594819bd5e945af18ec64bc"
checksum = "4dcd171b8776c41b97521e5da127a2d86ad280114807d0b2ab1e462bc764d9e1"

[[package]]
name = "windows_x86_64_msvc"
version = "0.32.0"
version = "0.36.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "504a2476202769977a040c6364301a3f65d0cc9e3fb08600b2bda150a0488316"
checksum = "c811ca4a8c853ef420abd8592ba53ddbbac90410fab6903b3e79972a631f7680"

[[package]]
name = "ws2_32-sys"
diff --git a/librad/Cargo.toml b/librad/Cargo.toml
index ea104419..52531b8d 100644
--- a/librad/Cargo.toml
+++ b/librad/Cargo.toml
@@ -107,6 +107,9 @@ features = ["git-ref-format"]
path = "../link-git"
features = ["git2"]

[dependencies.link-hooks]
path = "../link-hooks"

[dependencies.link-identities]
path = "../link-identities"

diff --git a/librad/src/git.rs b/librad/src/git.rs
index 9090ffde..4024812e 100644
--- a/librad/src/git.rs
+++ b/librad/src/git.rs
@@ -5,6 +5,7 @@

#[cfg(not(feature = "replication-v3"))]
pub mod fetch;
pub mod hooks;
pub mod identities;
pub mod include;
pub mod local;
diff --git a/librad/src/git/hooks.rs b/librad/src/git/hooks.rs
new file mode 100644
index 00000000..c8b0da14
--- /dev/null
+++ b/librad/src/git/hooks.rs
@@ -0,0 +1,66 @@
// Copyright © 2022 The Radicle Link Contributors
// SPDX-License-Identifier: GPL-3.0-or-later

use std::{fs, io, path::Path};

pub use link_hooks::{
    hook::{self, Handle, Hook, Hooks, Notification},
    Data,
    Track,
};
use tokio::process::Child;

use crate::paths::Paths;

pub const DATA: &str = "urn_changed";
pub const TRACK: &str = "tracking_changed";

/// Start the set of [`Hooks`] located under [`Paths::hooks_dir`].
///
/// Each hook must be a binary executable. If the executable deals with
/// changes to a [`crate::git::Urn`], then the hook should live under
/// the `hooks/urn_changed` directory. If the executable deals with
/// changes to a [`crate::git::tracking`], then the hook should live
/// under the `hooks/tracking_changed` directory.
///
/// # Usage
///
/// To start the processing of the hooks, call [`Hooks::run`] with a
/// [`futures::Stream`] of [`Notification`]s.
///
/// If the [`Notification`] is a [`Notification::Data`] then it will
/// be sent to all the `urn_changed` hooks.
///
/// If the [`Notification`] is a [`Notification::Track`] then it will
/// be sent to all the `tracking_changed` hooks.
pub async fn hooks(paths: &Paths, config: hook::Config) -> io::Result<Hooks<Child>> {
    let hooks_dir = paths.hooks_dir();
    let data_hooks = load(hooks_dir.join(DATA)).await?;
    let track_hooks = load(hooks_dir.join(TRACK)).await?;
    Ok(Hooks::new(config, data_hooks, track_hooks))
}

async fn load(dir: impl AsRef<Path>) -> io::Result<Vec<Hook<Child>>> {
    let dir = dir.as_ref();
    let mut hooks = Vec::new();
    for entry in fs::read_dir(dir)? {
        match entry {
            Ok(entry) => match entry.file_type() {
                Ok(file_type) if file_type.is_file() => {
                    hooks.push(Hook::spawn::<_, String>(entry.path(), None).await?)
                },
                Ok(file_type) => {
                    tracing::warn!(file_type = ?file_type, "skipping hook entry that is not a file")
                },
                Err(err) => {
                    tracing::warn!(directory = %dir.display(), err = %err, "skipping hook entry, could not resolve file type")
                },
            },
            Err(err) => {
                tracing::warn!(directory = %dir.display(), err = %err, "skipping hook entry")
            },
        }
    }

    Ok(hooks)
}
-- 
2.31.1
radicle-link/patches/nixos-latest.yml: FAILED in 25m26s

[Storage Hooks][0] from [Fintan Halpenny][1]

[0]: https://lists.sr.ht/~radicle-link/dev/patches/32680
[1]: mailto:fintan.halpenny@gmail.com

✗ #771712 FAILED radicle-link/patches/nixos-latest.yml https://builds.sr.ht/~radicle-link/job/771712

[PATCH radicle-link v2 5/6] link-hooks: smoke test running hooks Export this patch

Add smoke tests for executing a number of hooks.

This is achieved by adding executables under `test/hooks`:
* `echo-data` - handles `Data` messages
* `echo-track` - handles `Track` messages
* `echo-forever` - acts a misbehaving hook and sleeps to simulate a
  hang

The tests pass file handles to the corresponding hook and ensure that
all the notifications were written to the file once the `Hooks`
process is finished.

In the case of using `echo-forever`, we are ensuring that a hanging
hook does not poison the `Hooks` process and all hooks complete their
respective tasks.

Signed-off-by: Fintan Halpenny <fintan.halpenny@gmail.com>
---
 link-hooks/t/Cargo.toml               |  13 +++
 link-hooks/t/src/integration.rs       |   4 +
 link-hooks/t/src/integration/smoke.rs | 156 ++++++++++++++++++++++++++
 link-hooks/t/src/lib.rs               |   2 +
 test/hooks/Cargo.toml                 |   6 +
 test/hooks/echo-data/Cargo.toml       |  10 ++
 test/hooks/echo-data/src/main.rs      |  32 ++++++
 test/hooks/echo-forever/Cargo.toml    |  10 ++
 test/hooks/echo-forever/src/main.rs   |   6 +
 test/hooks/echo-track/Cargo.toml      |  10 ++
 test/hooks/echo-track/src/main.rs     |  32 ++++++
 11 files changed, 281 insertions(+)
 create mode 100644 link-hooks/t/src/integration.rs
 create mode 100644 link-hooks/t/src/integration/smoke.rs
 create mode 100644 test/hooks/Cargo.toml
 create mode 100644 test/hooks/echo-data/Cargo.toml
 create mode 100644 test/hooks/echo-data/src/main.rs
 create mode 100644 test/hooks/echo-forever/Cargo.toml
 create mode 100644 test/hooks/echo-forever/src/main.rs
 create mode 100644 test/hooks/echo-track/Cargo.toml
 create mode 100644 test/hooks/echo-track/src/main.rs

diff --git a/link-hooks/t/Cargo.toml b/link-hooks/t/Cargo.toml
index 2c3d2a14..fe867a12 100644
--- a/link-hooks/t/Cargo.toml
+++ b/link-hooks/t/Cargo.toml
@@ -40,5 +40,18 @@ features = ["test"]
[dependencies.radicle-git-ext]
path = "../../git-ext"

[dev-dependencies.link-async]
path = "../../link-async"

[dev-dependencies.futures]
version = "0.3"

[dev-dependencies.tempfile]
version = "3.3"

[dev-dependencies.test-helpers]
path = "../../test/test-helpers"

[dev-dependencies.tokio]
version = "1.18"
features = ["macros", "rt", "sync"]
diff --git a/link-hooks/t/src/integration.rs b/link-hooks/t/src/integration.rs
new file mode 100644
index 00000000..d25f269d
--- /dev/null
+++ b/link-hooks/t/src/integration.rs
@@ -0,0 +1,4 @@
// Copyright © 2022 The Radicle Link Contributors
// SPDX-License-Identifier: GPL-3.0-or-later

mod smoke;
diff --git a/link-hooks/t/src/integration/smoke.rs b/link-hooks/t/src/integration/smoke.rs
new file mode 100644
index 00000000..be0ac417
--- /dev/null
+++ b/link-hooks/t/src/integration/smoke.rs
@@ -0,0 +1,156 @@
// Copyright © 2022 The Radicle Link Contributors
// SPDX-License-Identifier: GPL-3.0-or-later

//! These tests rely on the executables found in `test/hooks`. There are three
//! executables:
//!   * `echo-data` - parses `Data` and writes it to the file path passed as an
//!     argument.
//!   * `echo-track` - parses `Track` and writes it to the file path passed as
//!     an argument.
//!   * `echo-forever` - hangs for 10s to ensure other hooks continue
//!     processing.

use std::{
    io::Read as _,
    iter,
    path::{Path, PathBuf},
};

use link_hooks::{
    hook::{self, Hook, Process as _},
    Data,
    Hooks,
    Notification,
    Track,
};
use radicle_git_ext::Oid;
use tempfile::NamedTempFile;
use test_helpers::logging;
use tokio::process::Child;

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_echo_hooks() {
    logging::init();

    let data_hook_path = setup_hook("data");
    let track_hook_path = setup_hook("track");
    let mut data_out = NamedTempFile::new().unwrap();
    let mut track_out = NamedTempFile::new().unwrap();
    let data_hooks = vec![Hook::<Child>::spawn(
        data_hook_path,
        Some(format!("{}", data_out.path().display())),
    )
    .await
    .unwrap()];
    let track_hooks = vec![Hook::<Child>::spawn(
        track_hook_path,
        Some(format!("{}", track_out.path().display())),
    )
    .await
    .unwrap()];

    let hooks = Hooks::new(hook::Config::default(), data_hooks, track_hooks);
    assert_notifications(hooks, &mut data_out, &mut track_out).await
}

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_hanging_hook() {
    logging::init();

    let data_hook_path = setup_hook("data");
    let track_hook_path = setup_hook("track");
    let forever_hook_path = setup_hook("forever");
    let mut data_out = NamedTempFile::new().unwrap();
    let mut track_out = NamedTempFile::new().unwrap();
    let data_hooks = vec![
        Hook::<Child>::spawn(
            data_hook_path,
            Some(format!("{}", data_out.path().display())),
        )
        .await
        .unwrap(),
        Hook::<Child>::spawn(forever_hook_path, None::<String>)
            .await
            .unwrap(),
    ];
    let track_hooks = vec![Hook::<Child>::spawn(
        track_hook_path,
        Some(format!("{}", track_out.path().display())),
    )
    .await
    .unwrap()];

    let hooks = Hooks::new(hook::Config::default(), data_hooks, track_hooks);
    assert_notifications(hooks, &mut data_out, &mut track_out).await
}

async fn assert_notifications(
    hooks: Hooks<Child>,
    data_out: &mut NamedTempFile,
    track_out: &mut NamedTempFile,
) {
    let notifications = vec![
        "rad:git:hnrkyzfpih4pqsw3cp1donkmwsgh9w5fwfdwo/refs/heads/main 0c3b4502a83a309b19123adc60a23e4e92bb13fb aeff7e8e964c47ba67a0c6eeba3beb62e29379d4\n".parse::<Data<Oid>>().unwrap().into(),
        "rad:git:hnrkyzfpih4pqsw3cp1donkmwsgh9w5fwfdwo hyyqpngdoe4x4oto3emfdppbw7sj1pfaghbpmmhz5rqiuqg8uofmeo 0c3b4502a83a309b19123adc60a23e4e92bb13fb aeff7e8e964c47ba67a0c6eeba3beb62e29379d4\n".parse::<Track<Oid>>().unwrap().into(),
        "rad:git:hnrkyzfpih4pqsw3cp1donkmwsgh9w5fwfdwo default 0c3b4502a83a309b19123adc60a23e4e92bb13fb aeff7e8e964c47ba67a0c6eeba3beb62e29379d4\n".parse::<Track<Oid>>().unwrap().into(),
        ];

    hooks
        .run(futures::stream::iter(notifications.clone()))
        .await;

    let expected = {
        let mut buf = String::new();
        data_out.read_to_string(&mut buf).unwrap();
        let expected = iter::once(Notification::from(buf.parse::<Data<Oid>>().unwrap()));

        let mut buf = String::new();
        track_out.read_to_string(&mut buf).unwrap();
        expected
            .chain(buf.split('\n').filter_map(|track| {
                if !track.is_empty() {
                    let mut track = track.to_owned();
                    track.push('\n');
                    Some(Notification::from(track.parse::<Track<Oid>>().unwrap()))
                } else {
                    None
                }
            }))
            .collect::<Vec<_>>()
    };

    assert_eq!(notifications, expected);
}

fn setup_hook(hook: &str) -> PathBuf {
    let test_path = Path::new(env!("CARGO_MANIFEST_DIR"));
    let root = test_path
        .parent()
        .unwrap()
        .parent()
        .unwrap()
        .join("test/hooks");
    let manifest = root.join(format!("echo-{}", hook)).join("Cargo.toml");
    let hook_path = root
        .join("target")
        .join("debug")
        .join(format!("echo-{}", hook));

    if !hook_path.exists() {
        let out = std::process::Command::new("cargo")
            .args(&[
                "build",
                "--bin",
                &format!("echo-{}", hook),
                "--manifest-path",
                &format!("{}", manifest.display()),
            ])
            .output()
            .unwrap();
        if !out.status.success() {
            println!("{:#?}", out)
        }
    }

    hook_path
}
diff --git a/link-hooks/t/src/lib.rs b/link-hooks/t/src/lib.rs
index 16452050..af4f8834 100644
--- a/link-hooks/t/src/lib.rs
+++ b/link-hooks/t/src/lib.rs
@@ -4,5 +4,7 @@
#[cfg(any(test, feature = "test"))]
pub mod gen;

#[cfg(test)]
mod integration;
#[cfg(test)]
mod properties;
diff --git a/test/hooks/Cargo.toml b/test/hooks/Cargo.toml
new file mode 100644
index 00000000..b2389438
--- /dev/null
+++ b/test/hooks/Cargo.toml
@@ -0,0 +1,6 @@
[workspace]
members = [
  "echo-data",
  "echo-forever",
  "echo-track",
]
diff --git a/test/hooks/echo-data/Cargo.toml b/test/hooks/echo-data/Cargo.toml
new file mode 100644
index 00000000..a7868f23
--- /dev/null
+++ b/test/hooks/echo-data/Cargo.toml
@@ -0,0 +1,10 @@
[package]
name = "echo-data"
version = "0.1.0"
edition = "2021"

[dependencies.link-hooks]
path = "../../../link-hooks"

[dependencies.radicle-git-ext]
path = "../../../git-ext"
\ No newline at end of file
diff --git a/test/hooks/echo-data/src/main.rs b/test/hooks/echo-data/src/main.rs
new file mode 100644
index 00000000..d95b3fc9
--- /dev/null
+++ b/test/hooks/echo-data/src/main.rs
@@ -0,0 +1,32 @@
use std::{
    env,
    fs,
    io::{self, Write as _},
};

use link_hooks::{hook::HookMessage, Data};
use radicle_git_ext::Oid;

type Message = HookMessage<Data<Oid>>;

fn main() {
    let mut args = env::args();
    let _ = args.next();
    let out = args.next().expect("expected output path");
    let mut file = fs::File::create(out).unwrap();

    let mut buffer = String::new();
    let stdin = io::stdin();
    let mut eot = false;

    while !eot {
        stdin.read_line(&mut buffer).unwrap();
        match buffer.parse::<Message>().unwrap() {
            HookMessage::EOT => {
                eot = true;
            },
            HookMessage::Payload(data) => file.write_all(format!("{}", data).as_bytes()).unwrap(),
        }
        buffer.clear();
    }
}
diff --git a/test/hooks/echo-forever/Cargo.toml b/test/hooks/echo-forever/Cargo.toml
new file mode 100644
index 00000000..e786d408
--- /dev/null
+++ b/test/hooks/echo-forever/Cargo.toml
@@ -0,0 +1,10 @@
[package]
name = "echo-forever"
version = "0.1.0"
edition = "2021"

[dependencies.link-hooks]
path = "../../../link-hooks"

[dependencies.radicle-git-ext]
path = "../../../git-ext"
diff --git a/test/hooks/echo-forever/src/main.rs b/test/hooks/echo-forever/src/main.rs
new file mode 100644
index 00000000..9a26a8af
--- /dev/null
+++ b/test/hooks/echo-forever/src/main.rs
@@ -0,0 +1,6 @@
use std::{thread, time::Duration};

fn main() {
    // simulate a hook hanging
    thread::sleep(Duration::from_secs(10));
}
diff --git a/test/hooks/echo-track/Cargo.toml b/test/hooks/echo-track/Cargo.toml
new file mode 100644
index 00000000..172cee8e
--- /dev/null
+++ b/test/hooks/echo-track/Cargo.toml
@@ -0,0 +1,10 @@
[package]
name = "echo-track"
version = "0.1.0"
edition = "2021"

[dependencies.link-hooks]
path = "../../../link-hooks"

[dependencies.radicle-git-ext]
path = "../../../git-ext"
\ No newline at end of file
diff --git a/test/hooks/echo-track/src/main.rs b/test/hooks/echo-track/src/main.rs
new file mode 100644
index 00000000..5ea033a6
--- /dev/null
+++ b/test/hooks/echo-track/src/main.rs
@@ -0,0 +1,32 @@
use std::{
    env,
    fs,
    io::{self, Write as _},
};

use link_hooks::{hook::HookMessage, Track};
use radicle_git_ext::Oid;

type Message = HookMessage<Track<Oid>>;

fn main() {
    let mut args = env::args();
    let _ = args.next();
    let out = args.next().expect("expected output path");
    let mut file = fs::File::create(out).unwrap();

    let mut buffer = String::new();
    let stdin = io::stdin();
    let mut eot = false;

    while !eot {
        stdin.read_line(&mut buffer).unwrap();
        match buffer.parse::<Message>().unwrap() {
            HookMessage::EOT => {
                eot = true;
            },
            HookMessage::Payload(track) => file.write_all(format!("{}", track).as_bytes()).unwrap(),
        }
        buffer.clear();
    }
}
-- 
2.31.1

[PATCH radicle-link v2 6/6] librad: wire up link-hooks Export this patch

Add `librad::git::hooks` to expose the `link-hooks` API through the
`hooks` constructor.

This handles the spawning of individual hooks under the `urn_changed`
and `tracking_changed` directories.

Signed-off-by: Fintan Halpenny <fintan.halpenny@gmail.com>
---
 bins/Cargo.lock         | 458 +++++++++++++++++++++-------------------
 librad/Cargo.toml       |   3 +
 librad/src/git.rs       |   1 +
 librad/src/git/hooks.rs |  66 ++++++
 4 files changed, 314 insertions(+), 214 deletions(-)
 create mode 100644 librad/src/git/hooks.rs

diff --git a/bins/Cargo.lock b/bins/Cargo.lock
index 4cb8dd64..cae3d94e 100644
--- a/bins/Cargo.lock
@@ -42,7 +42,7 @@ version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fcb51a0695d8f838b1ee009b3fbf66bda078cd64590202a864a8f3e8c4315c47"
dependencies = [
 "getrandom 0.2.5",
 "getrandom 0.2.6",
 "once_cell",
 "serde",
 "version_check",
@@ -68,9 +68,9 @@ dependencies = [

[[package]]
name = "anyhow"
version = "1.0.56"
version = "1.0.57"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4361135be9122e0870de935d7c439aef945b9f9ddd4199a553b5270b49c82a27"
checksum = "08f9b8508dccb7687a1d6c4ce66b2b0ecef467c94667de27d8d7fe1f8d2a9cdc"

[[package]]
name = "arc-swap"
@@ -122,9 +122,9 @@ dependencies = [

[[package]]
name = "async-io"
version = "1.6.0"
version = "1.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a811e6a479f2439f0c04038796b5cfb3d2ad56c230e0f2d3f7b04d68cfee607b"
checksum = "e5e18f61464ae81cde0a23e713ae8fd299580c54d697a35820cfd0625b8b0e07"
dependencies = [
 "concurrent-queue",
 "futures-lite",
@@ -150,9 +150,9 @@ dependencies = [

[[package]]
name = "async-process"
version = "1.3.0"
version = "1.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "83137067e3a2a6a06d67168e49e68a0957d215410473a740cea95a2425c0b7c6"
checksum = "cf2c06e30a24e8c78a3987d07f0930edf76ef35e027e7bdb063fccafdad1f60c"
dependencies = [
 "async-io",
 "blocking",
@@ -194,9 +194,9 @@ checksum = "30696a84d817107fc028e049980e09d5e140e8da8f1caeb17e8e950658a3cea9"

[[package]]
name = "async-trait"
version = "0.1.52"
version = "0.1.53"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "061a7acccaa286c011ddc30970520b98fa40e00c9d644633fb26b5fc63a265e3"
checksum = "ed6aa3524a2dfcf9fe180c51eae2b58738348d819517ceadf95789c51fff7600"
dependencies = [
 "proc-macro2",
 "quote",
@@ -271,7 +271,7 @@ version = "0.1.0"
source = "git+https://github.com/automerge/automerge-rs.git?rev=e72571962b51c2f0726fb534890ef3b4f7c74dfc#e72571962b51c2f0726fb534890ef3b4f7c74dfc"
dependencies = [
 "automerge-protocol",
 "getrandom 0.2.5",
 "getrandom 0.2.6",
 "maplit",
 "serde",
 "serde_json",
@@ -301,16 +301,16 @@ version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9fe17f59a06fe8b87a6fc8bf53bb70b3aba76d7685f432487a68cd5552853625"
dependencies = [
 "getrandom 0.2.5",
 "getrandom 0.2.6",
 "instant",
 "rand",
]

[[package]]
name = "base-x"
version = "0.2.8"
version = "0.2.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4521f3e3d031370679b3b140beb36dfe4801b09ac77e30c61941f97df3ef28b"
checksum = "dc19a4937b4fbd3fe3379793130e42060d10627a360f2127802b10b87e7baf74"

[[package]]
name = "base64"
@@ -320,9 +320,9 @@ checksum = "904dfeac50f3cdaba28fc6f57fdcddb75f49ed61346676a78c4ffe55877802fd"

[[package]]
name = "base64ct"
version = "1.4.1"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "71acf5509fc522cce1b100ac0121c635129bfd4d91cdf036bcc9b9935f97ccf5"
checksum = "dea908e7347a8c64e378c17e30ef880ad73e3b4498346b055c2c00ea342f3179"

[[package]]
name = "bcrypt-pbkdf"
@@ -568,16 +568,16 @@ dependencies = [

[[package]]
name = "clap"
version = "3.1.6"
version = "3.1.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d8c93436c21e4698bacadf42917db28b23017027a4deccb35dbe47a7e7840123"
checksum = "d2dbdf4bdacb33466e854ce889eee8dfd5729abf7ccd7664d0a2d60cd384440b"
dependencies = [
 "atty",
 "bitflags",
 "clap_derive",
 "clap_lex",
 "indexmap",
 "lazy_static",
 "os_str_bytes 6.0.0",
 "strsim",
 "termcolor",
 "textwrap",
@@ -585,9 +585,9 @@ dependencies = [

[[package]]
name = "clap_derive"
version = "3.1.4"
version = "3.1.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da95d038ede1a964ce99f49cbe27a7fb538d1da595e4b4f70b8c8f338d17bf16"
checksum = "25320346e922cffe59c0bbc5410c8d8784509efb321488971081313cb1e1a33c"
dependencies = [
 "heck 0.4.0",
 "proc-macro-error",
@@ -596,6 +596,15 @@ dependencies = [
 "syn",
]

[[package]]
name = "clap_lex"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a37c35f1112dad5e6e0b1adaff798507497a18fceeb30cceb3bae7d1427b9213"
dependencies = [
 "os_str_bytes 6.1.0",
]

[[package]]
name = "clru"
version = "0.5.0"
@@ -661,9 +670,9 @@ checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e"

[[package]]
name = "cpufeatures"
version = "0.2.1"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "95059428f66df56b63431fdb4e1947ed2190586af5c5a8a8b71122bdf5a7f469"
checksum = "59a6001667ab124aebae2a495118e11d30984c3a653e99d86d58971708cf5e4b"
dependencies = [
 "libc",
]
@@ -693,9 +702,9 @@ dependencies = [

[[package]]
name = "crossbeam-channel"
version = "0.5.2"
version = "0.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e54ea8bc3fb1ee042f5aace6e3c6e025d3874866da222930f70ce62aceba0bfa"
checksum = "5aaa7bd5fb665c6864b5f963dd9097905c54125909c7aa94c9e18507cdbe6c53"
dependencies = [
 "cfg-if 1.0.0",
 "crossbeam-utils",
@@ -714,10 +723,11 @@ dependencies = [

[[package]]
name = "crossbeam-epoch"
version = "0.9.7"
version = "0.9.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c00d6d2ea26e8b151d99093005cb442fb9a37aeaca582a03ec70946f49ab5ed9"
checksum = "1145cf131a2c6ba0615079ab6a638f7e1973ac9c2634fcbeaaad6114246efe8c"
dependencies = [
 "autocfg",
 "cfg-if 1.0.0",
 "crossbeam-utils",
 "lazy_static",
@@ -727,9 +737,9 @@ dependencies = [

[[package]]
name = "crossbeam-queue"
version = "0.3.4"
version = "0.3.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4dd435b205a4842da59efd07628f921c096bc1cc0a156835b4fa0bcb9a19bcce"
checksum = "1f25d8400f4a7a5778f0e4e52384a48cbd9b5c495d110786187fc750075277a2"
dependencies = [
 "cfg-if 1.0.0",
 "crossbeam-utils",
@@ -737,9 +747,9 @@ dependencies = [

[[package]]
name = "crossbeam-utils"
version = "0.8.7"
version = "0.8.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b5e5bed1f1c269533fa816a0a5492b3545209a205ca1a54842be180eb63a16a6"
checksum = "0bf124c720b7686e3c2663cf54062ab0f68a88af2fb6a030e87e30bf721fcb38"
dependencies = [
 "cfg-if 1.0.0",
 "lazy_static",
@@ -766,9 +776,9 @@ dependencies = [

[[package]]
name = "curve25519-dalek"
version = "3.2.0"
version = "3.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b9fdf9972b2bd6af2d913799d9ebc165ea4d2e65878e329d9c6b372c4491b61"
checksum = "90f9d052967f590a76e62eb387bd0bbb1b000182c3cefe5364db6b7211651bc0"
dependencies = [
 "byteorder",
 "digest",
@@ -789,13 +799,14 @@ dependencies = [

[[package]]
name = "dashmap"
version = "5.1.0"
version = "5.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c0834a35a3fce649144119e18da2a4d8ed12ef3862f47183fd46f625d072d96c"
checksum = "3495912c9c1ccf2e18976439f4443f3fee0fd61f424ff99fde6a66b15ecb448f"
dependencies = [
 "cfg-if 1.0.0",
 "num_cpus",
 "parking_lot 0.12.0",
 "hashbrown 0.12.1",
 "lock_api",
 "parking_lot_core 0.9.3",
]

[[package]]
@@ -874,9 +885,9 @@ dependencies = [

[[package]]
name = "dirs-sys"
version = "0.3.6"
version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "03d86534ed367a67548dc68113a0f5db55432fdfbb6e6f9d77704397d95d5780"
checksum = "1b1d1d91c932ef41c0f2663aa8b0ca0342d444d842c06914aa0a7e352d0bada6"
dependencies = [
 "libc",
 "redox_users",
@@ -885,9 +896,9 @@ dependencies = [

[[package]]
name = "dyn-clone"
version = "1.0.4"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee2626afccd7561a06cf1367e2950c4718ea04565e20fb5029b6c7d8ad09abcf"
checksum = "21e50f3adc76d6a43f5ed73b698a87d0760ca74617f60f7c3b879003536fdd28"

[[package]]
name = "ed25519-zebra"
@@ -963,9 +974,9 @@ dependencies = [

[[package]]
name = "filetime"
version = "0.2.15"
version = "0.2.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "975ccf83d8d9d0d84682850a38c8169027be83368805971cc4f238c2b245bc98"
checksum = "c0408e2626025178a6a7f7ffc05a25bc47103229f19c113755de7bf63816290c"
dependencies = [
 "cfg-if 1.0.0",
 "libc",
@@ -981,13 +992,11 @@ checksum = "37ab347416e802de484e4d03c7316c48f1ecb56574dfd4a46a80f173ce1de04d"

[[package]]
name = "flate2"
version = "1.0.22"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e6988e897c1c9c485f43b47a529cef42fde0547f9d8d41a7062518f1d8fc53f"
checksum = "f82b0f4c27ad9f8bfd1f3208d882da2b09c301bc1c828fd3a00d0216d2fbbff6"
dependencies = [
 "cfg-if 1.0.0",
 "crc32fast",
 "libc",
 "libz-sys",
 "miniz_oxide",
]
@@ -1208,9 +1217,9 @@ dependencies = [

[[package]]
name = "getrandom"
version = "0.2.5"
version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d39cd93900197114fa1fcb7ae84ca742095eed9442088988ae74fa744e930e77"
checksum = "9be70c98951c83b8d2f8f60d7065fa6d5146873094452a1008da8c2f1e4205ad"
dependencies = [
 "cfg-if 1.0.0",
 "js-sys",
@@ -1341,9 +1350,9 @@ dependencies = [

[[package]]
name = "git-packetline"