/*
 * Copyright (c) Meta Platforms, Inc. and affiliates.
 *
 * This software may be used and distributed according to the terms of the
 * GNU General Public License version 2.
 */

use std::collections::hash_map::Entry;
use std::collections::HashMap;
use std::collections::HashSet;

use anyhow::Error;
use async_trait::async_trait;
use blobstore::Loadable;
use bookmarks::BookmarkKey;
use bookmarks::BookmarkUpdateReason;
use bookmarks::BookmarksRef;
use context::CoreContext;
use fbinit::FacebookInit;
use fixtures::TestRepoFixture;
use futures::future;
use futures::stream::futures_unordered;
use futures::stream::TryStreamExt;
use futures::TryFutureExt;
use hooks::hook_loader::load_hooks;
use hooks::ChangesetHook;
use hooks::CrossRepoPushSource;
use hooks::ErrorKind;
use hooks::FileHook;
use hooks::HookExecution;
use hooks::HookManager;
use hooks::HookRejectionInfo;
use hooks::PushAuthoredBy;
use hooks_content_stores::FileChange as FileDiff;
use hooks_content_stores::FileContentManager;
use hooks_content_stores::InMemoryFileContentManager;
use hooks_content_stores::PathContent;
use hooks_content_stores::RepoFileContentManager;
use maplit::btreemap;
use maplit::hashmap;
use maplit::hashset;
use metaconfig_types::BookmarkParams;
use metaconfig_types::HookManagerParams;
use metaconfig_types::HookParams;
use metaconfig_types::RepoConfig;
use mononoke_types::BasicFileChange;
use mononoke_types::BonsaiChangeset;
use mononoke_types::BonsaiChangesetMut;
use mononoke_types::ChangesetId;
use mononoke_types::DateTime;
use mononoke_types::FileChange;
use mononoke_types::FileType;
use mononoke_types::MPath;
use mononoke_types_mocks::contentid::ONES_CTID;
use mononoke_types_mocks::contentid::THREES_CTID;
use mononoke_types_mocks::contentid::TWOS_CTID;
use permission_checker::DefaultAclProvider;
use regex::Regex;
use repo_blobstore::RepoBlobstoreRef;
use scuba_ext::MononokeScubaSampleBuilder;
use sorted_vector_map::sorted_vector_map;
use tests_utils::bookmark;
use tests_utils::create_commit;
use tests_utils::store_files;
use tests_utils::BasicTestRepo;
use tests_utils::CreateCommitContext;

#[derive(Clone, Debug)]
struct FnChangesetHook {
    f: fn() -> HookExecution,
}

impl FnChangesetHook {
    fn new(f: fn() -> HookExecution) -> FnChangesetHook {
        FnChangesetHook { f }
    }
}

#[async_trait]
impl ChangesetHook for FnChangesetHook {
    async fn run<'this: 'cs, 'ctx: 'this, 'cs, 'fetcher: 'cs>(
        &'this self,
        _ctx: &'ctx CoreContext,
        _bookmark: &BookmarkKey,
        _changeset: &'cs BonsaiChangeset,
        _content_manager: &'fetcher dyn FileContentManager,
        _cross_repo_push_source: CrossRepoPushSource,
        _push_authored_by: PushAuthoredBy,
    ) -> Result<HookExecution, Error> {
        Ok((self.f)())
    }
}

fn always_accepting_changeset_hook() -> Box<dyn ChangesetHook> {
    let f: fn() -> HookExecution = || HookExecution::Accepted;
    Box::new(FnChangesetHook::new(f))
}

fn always_rejecting_changeset_hook() -> Box<dyn ChangesetHook> {
    let f: fn() -> HookExecution = default_rejection;
    Box::new(FnChangesetHook::new(f))
}

#[derive(Clone)]
struct FindFilesChangesetHook {
    pub filename: String,
}

#[async_trait]
impl ChangesetHook for FindFilesChangesetHook {
    async fn run<'this: 'cs, 'ctx: 'this, 'cs, 'fetcher: 'cs>(
        &'this self,
        ctx: &'ctx CoreContext,
        _bookmark: &BookmarkKey,
        _changeset: &'cs BonsaiChangeset,
        content_manager: &'fetcher dyn FileContentManager,
        _cross_repo_push_source: CrossRepoPushSource,
        _push_authored_by: PushAuthoredBy,
    ) -> Result<HookExecution, Error> {
        let path = to_mpath(self.filename.as_str());
        let res = content_manager
            .find_content(ctx, BookmarkKey::new("master")?, vec![path.clone()])
            .await;

        match res {
            Ok(contents) => Ok(match contents.get(&path) {
                Some(PathContent::File(_)) => HookExecution::Accepted,
                _ => HookExecution::Rejected(HookRejectionInfo::new("there is no such file")),
            }),
            Err(err) => {
                if err.to_string().contains("Bookmark master does not exist") {
                    return Ok(HookExecution::Rejected(HookRejectionInfo::new(
                        "no master bookmark found",
                    )));
                }
                Err(err).map_err(Error::from)
            }
        }
    }
}

#[derive(Clone)]
struct FileChangesChangesetHook {
    pub added: i32,
    pub changed: i32,
    pub removed: i32,
}

#[async_trait]
impl ChangesetHook for FileChangesChangesetHook {
    async fn run<'this: 'cs, 'ctx: 'this, 'cs, 'fetcher: 'cs>(
        &'this self,
        ctx: &'ctx CoreContext,
        _bookmark: &BookmarkKey,
        changeset: &'cs BonsaiChangeset,
        content_manager: &'fetcher dyn FileContentManager,
        _cross_repo_push_source: CrossRepoPushSource,
        _push_authored_by: PushAuthoredBy,
    ) -> Result<HookExecution, Error> {
        let parent = changeset.parents().next();
        let (added, changed, removed) = if let Some(parent) = parent {
            let file_changes = content_manager
                .file_changes(ctx, changeset.get_changeset_id(), parent)
                .await?;

            let (mut added, mut changed, mut removed) = (0, 0, 0);
            for (_path, change) in file_changes.into_iter() {
                match change {
                    FileDiff::Added(_) => added += 1,
                    FileDiff::Changed(_, _) => changed += 1,
                    FileDiff::Removed => removed += 1,
                }
            }
            Result::<_, Error>::Ok((added, changed, removed))
        } else {
            Ok((0, 0, 0))
        }?;

        if added != self.added || changed != self.changed || removed != self.removed {
            return Ok(HookExecution::Rejected(HookRejectionInfo::new(
                "Wrong number of added, changed or removed files",
            )));
        }

        Ok(HookExecution::Accepted)
    }
}

#[derive(Clone, Debug)]
struct FileContentMatchingChangesetHook {
    expected_content: HashMap<MPath, Option<String>>,
}

#[derive(Clone)]
struct LatestChangesChangesetHook(HashMap<MPath, Option<ChangesetId>>);

#[async_trait]
impl ChangesetHook for LatestChangesChangesetHook {
    async fn run<'this: 'cs, 'ctx: 'this, 'cs, 'fetcher: 'cs>(
        &'this self,
        ctx: &'ctx CoreContext,
        _bookmark: &BookmarkKey,
        _changeset: &'cs BonsaiChangeset,
        content_manager: &'fetcher dyn FileContentManager,
        _cross_repo_push_source: CrossRepoPushSource,
        _push_authored_by: PushAuthoredBy,
    ) -> Result<HookExecution, Error> {
        let paths = self.0.keys().cloned().collect();
        let res = content_manager
            .latest_changes(ctx, BookmarkKey::new("master")?, paths)
            .map_err(Error::from)
            .await?;

        for (path, linknode) in self.0.iter() {
            let found_linknode = res.get(path).map(|info| info.changeset_id());
            if linknode.as_ref() != found_linknode {
                return Ok(HookExecution::Rejected(HookRejectionInfo::new(
                    "found linknode doesn't match the expected one",
                )));
            }
        }
        Ok(HookExecution::Accepted)
    }
}

#[async_trait]
impl ChangesetHook for FileContentMatchingChangesetHook {
    async fn run<'this: 'cs, 'ctx: 'this, 'cs, 'fetcher: 'cs>(
        &'this self,
        ctx: &'ctx CoreContext,
        _bookmark: &BookmarkKey,
        changeset: &'cs BonsaiChangeset,
        content_manager: &'fetcher dyn FileContentManager,
        _cross_repo_push_source: CrossRepoPushSource,
        _push_authored_by: PushAuthoredBy,
    ) -> Result<HookExecution, Error> {
        let futs = futures_unordered::FuturesUnordered::new();

        for (path, change) in changeset.simplified_file_changes() {
            // If we have a change to a path, but no expected change, fail
            let expected_content = match self.expected_content.get(path) {
                Some(expected) => expected,
                None => return Ok(default_rejection()),
            };

            match change {
                Some(change) => {
                    let fut = async move {
                        let content = content_manager
                            .get_file_text(ctx, change.content_id())
                            .await?;
                        let content =
                            content.map(|c| std::str::from_utf8(c.as_ref()).unwrap().to_string());

                        // True only if there is content containing the expected content
                        Ok(match (content, expected_content.as_ref()) {
                            (Some(content), Some(expected_content)) => {
                                content.contains(expected_content)
                            }
                            (None, None) => true,
                            _ => false,
                        })
                    };
                    futs.push(fut);
                }
                None => {
                    // If we have a deletion, but expect it to be present, fail
                    if expected_content.is_some() {
                        return Ok(default_rejection());
                    }
                }
            }
        }

        let opt_item = futs
            .try_skip_while(|b: &bool| future::ok::<_, Error>(*b))
            .try_next()
            .await?;
        Ok(if opt_item.is_some() {
            default_rejection()
        } else {
            HookExecution::Accepted
        })
    }
}

fn file_text_matching_changeset_hook(
    expected_content: HashMap<MPath, Option<String>>,
) -> Box<dyn ChangesetHook> {
    Box::new(FileContentMatchingChangesetHook { expected_content })
}

#[derive(Clone, Debug)]
struct LengthMatchingChangesetHook {
    expected_lengths: HashMap<MPath, u64>,
}

#[async_trait]
impl ChangesetHook for LengthMatchingChangesetHook {
    async fn run<'this: 'cs, 'ctx: 'this, 'cs, 'fetcher: 'cs>(
        &'this self,
        ctx: &'ctx CoreContext,
        _bookmark: &BookmarkKey,
        changeset: &'cs BonsaiChangeset,
        content_manager: &'fetcher dyn FileContentManager,
        _cross_repo_push_source: CrossRepoPushSource,
        _push_authored_by: PushAuthoredBy,
    ) -> Result<HookExecution, Error> {
        let futs = futures_unordered::FuturesUnordered::new();
        for (path, change) in changeset.simplified_file_changes() {
            let expected_length = self.expected_lengths.get(path);

            match change {
                Some(change) => {
                    let fut = async move {
                        let size = content_manager
                            .get_file_size(ctx, change.content_id())
                            .await?;

                        Ok(expected_length == Some(size).as_ref())
                    };
                    futs.push(fut);
                }
                None => {
                    if expected_length.is_some() {
                        return Ok(default_rejection());
                    }
                }
            }
        }
        let opt_item = futs
            .try_skip_while(|b: &bool| future::ok::<_, Error>(*b))
            .try_next()
            .await?;
        Ok(if opt_item.is_some() {
            default_rejection()
        } else {
            HookExecution::Accepted
        })
    }
}

fn length_matching_changeset_hook(expected_lengths: HashMap<MPath, u64>) -> Box<dyn ChangesetHook> {
    Box::new(LengthMatchingChangesetHook { expected_lengths })
}

#[derive(Clone, Debug)]
struct FnFileHook {
    f: fn() -> HookExecution,
}

impl FnFileHook {
    fn new(f: fn() -> HookExecution) -> FnFileHook {
        FnFileHook { f }
    }
}

#[async_trait]
impl FileHook for FnFileHook {
    async fn run<'this: 'change, 'ctx: 'this, 'change, 'fetcher: 'change, 'path: 'change>(
        &'this self,
        _ctx: &'ctx CoreContext,
        _content_manager: &'fetcher dyn FileContentManager,
        _change: Option<&'change BasicFileChange>,
        _path: &'path MPath,
        _cross_repo_push_source: CrossRepoPushSource,
        _push_authored_by: PushAuthoredBy,
    ) -> Result<HookExecution, Error> {
        Ok((self.f)())
    }
}

fn always_accepting_file_hook() -> Box<dyn FileHook> {
    let f: fn() -> HookExecution = || HookExecution::Accepted;
    Box::new(FnFileHook::new(f))
}

fn always_rejecting_file_hook() -> Box<dyn FileHook> {
    let f: fn() -> HookExecution = default_rejection;
    Box::new(FnFileHook::new(f))
}

#[derive(Clone, Debug)]
struct PathMatchingFileHook {
    paths: HashSet<MPath>,
}

#[async_trait]
impl FileHook for PathMatchingFileHook {
    async fn run<'this: 'change, 'ctx: 'this, 'change, 'fetcher: 'change, 'path: 'change>(
        &'this self,
        _ctx: &'ctx CoreContext,
        _content_manager: &'fetcher dyn FileContentManager,
        _change: Option<&'change BasicFileChange>,
        path: &'path MPath,
        _cross_repo_push_source: CrossRepoPushSource,
        _push_authored_by: PushAuthoredBy,
    ) -> Result<HookExecution, Error> {
        Ok(if self.paths.contains(path) {
            HookExecution::Accepted
        } else {
            default_rejection()
        })
    }
}

fn path_matching_file_hook(paths: HashSet<MPath>) -> Box<dyn FileHook> {
    Box::new(PathMatchingFileHook { paths })
}

#[derive(Clone, Debug)]
struct FileContentMatchingFileHook {
    expected_content: Option<String>,
}

#[async_trait]
impl FileHook for FileContentMatchingFileHook {
    async fn run<'this: 'change, 'ctx: 'this, 'change, 'fetcher: 'change, 'path: 'change>(
        &'this self,
        ctx: &'ctx CoreContext,
        content_manager: &'fetcher dyn FileContentManager,
        change: Option<&'change BasicFileChange>,
        _path: &'path MPath,
        _cross_repo_push_source: CrossRepoPushSource,
        _push_authored_by: PushAuthoredBy,
    ) -> Result<HookExecution, Error> {
        match change {
            Some(change) => {
                let content = content_manager
                    .get_file_text(ctx, change.content_id())
                    .await?;
                let content = content.map(|c| std::str::from_utf8(c.as_ref()).unwrap().to_string());
                Ok(match (content, self.expected_content.as_ref()) {
                    (Some(content), Some(expected_content)) => {
                        if content.contains(expected_content) {
                            HookExecution::Accepted
                        } else {
                            default_rejection()
                        }
                    }
                    (None, None) => HookExecution::Accepted,
                    _ => default_rejection(),
                })
            }

            None => Ok(default_rejection()),
        }
    }
}

fn file_text_matching_file_hook(expected_content: Option<String>) -> Box<dyn FileHook> {
    Box::new(FileContentMatchingFileHook { expected_content })
}

#[derive(Clone, Debug)]
struct IsSymLinkMatchingFileHook {
    is_symlink: bool,
}

#[async_trait]
impl FileHook for IsSymLinkMatchingFileHook {
    async fn run<'this: 'change, 'ctx: 'this, 'change, 'fetcher: 'change, 'path: 'change>(
        &'this self,
        _ctx: &'ctx CoreContext,
        _content_manager: &'fetcher dyn FileContentManager,
        change: Option<&'change BasicFileChange>,
        _path: &'path MPath,
        _cross_repo_push_source: CrossRepoPushSource,
        _push_authored_by: PushAuthoredBy,
    ) -> Result<HookExecution, Error> {
        let is_symlink = match change {
            Some(change) => change.file_type() == FileType::Symlink,
            None => false,
        };
        Ok(if self.is_symlink == is_symlink {
            HookExecution::Accepted
        } else {
            default_rejection()
        })
    }
}

fn is_symlink_matching_file_hook(is_symlink: bool) -> Box<dyn FileHook> {
    Box::new(IsSymLinkMatchingFileHook { is_symlink })
}

#[derive(Clone, Debug)]
struct LengthMatchingFileHook {
    length: u64,
}

#[async_trait]
impl FileHook for LengthMatchingFileHook {
    async fn run<'this: 'change, 'ctx: 'this, 'change, 'fetcher: 'change, 'path: 'change>(
        &'this self,
        ctx: &'ctx CoreContext,
        content_manager: &'fetcher dyn FileContentManager,
        change: Option<&'change BasicFileChange>,
        _path: &'path MPath,
        _cross_repo_push_source: CrossRepoPushSource,
        _push_authored_by: PushAuthoredBy,
    ) -> Result<HookExecution, Error> {
        let length = match change {
            Some(change) => {
                content_manager
                    .get_file_size(ctx, change.content_id())
                    .await?
            }
            None => return Ok(HookExecution::Accepted),
        };
        if length == self.length {
            return Ok(HookExecution::Accepted);
        }
        Ok(default_rejection())
    }
}

fn length_matching_file_hook(length: u64) -> Box<dyn FileHook> {
    Box::new(LengthMatchingFileHook { length })
}

#[fbinit::test]
async fn test_changeset_hook_accepted(fb: FacebookInit) {
    let ctx = CoreContext::test_mock(fb);
    let hooks: HashMap<String, Box<dyn ChangesetHook>> = hashmap! {
        "hook1".to_string() => always_accepting_changeset_hook()
    };
    let bookmarks = hashmap! {
        "bm1".to_string() => vec!["hook1".to_string()]
    };
    let regexes = hashmap! {};
    let expected = hashmap! {
        "hook1".to_string() => HookExecution::Accepted
    };
    run_changeset_hooks(ctx, "bm1", hooks, bookmarks, regexes, expected).await;
}

#[fbinit::test]
async fn test_changeset_hook_rejected(fb: FacebookInit) {
    let ctx = CoreContext::test_mock(fb);
    let hooks: HashMap<String, Box<dyn ChangesetHook>> = hashmap! {
        "hook1".to_string() => always_rejecting_changeset_hook()
    };
    let bookmarks = hashmap! {
        "bm1".to_string() => vec!["hook1".to_string()]
    };
    let regexes = hashmap! {};
    let expected = hashmap! {
        "hook1".to_string() => default_rejection()
    };
    run_changeset_hooks(ctx, "bm1", hooks, bookmarks, regexes, expected).await;
}

#[fbinit::test]
async fn test_changeset_hook_mix(fb: FacebookInit) {
    let ctx = CoreContext::test_mock(fb);
    let hooks: HashMap<String, Box<dyn ChangesetHook>> = hashmap! {
        "hook1".to_string() => always_accepting_changeset_hook(),
        "hook2".to_string() => always_rejecting_changeset_hook(),
        "hook3".to_string() => always_accepting_changeset_hook(),
    };
    let bookmarks = hashmap! {
        "bm1".to_string() => vec!["hook1".to_string(), "hook2".to_string()]
    };
    let regexes = hashmap! {
        "b.*".to_string() => vec!["hook3".to_string()],
    };
    let expected = hashmap! {
        "hook1".to_string() => HookExecution::Accepted,
        "hook2".to_string() => default_rejection(),
        "hook3".to_string() => HookExecution::Accepted,
    };
    run_changeset_hooks(ctx, "bm1", hooks, bookmarks, regexes, expected).await;
}

#[fbinit::test]
async fn test_changeset_hook_file_text(fb: FacebookInit) {
    let ctx = CoreContext::test_mock(fb);
    let hook1_map = hashmap![
        to_mpath("dir1/subdir1/subsubdir1/file_1") => Some("elephants".to_string()),
        to_mpath("dir1/subdir1/subsubdir2/file_1") => Some("hippopatami".to_string()),
        to_mpath("dir1/subdir1/subsubdir2/file_2") => Some("eels".to_string()),
    ];
    let hook2_map = hashmap![
        to_mpath("dir1/subdir1/subsubdir1/file_1") => Some("anteaters".to_string()),
        to_mpath("dir1/subdir1/subsubdir2/file_1") => Some("hippopatami".to_string()),
        to_mpath("dir1/subdir1/subsubdir2/file_2") => Some("eels".to_string()),
    ];
    let hook3_map = hashmap![
        to_mpath("dir1/subdir1/subsubdir1/file_1") => Some("anteaters".to_string()),
        to_mpath("dir1/subdir1/subsubdir2/file_1") => Some("giraffes".to_string()),
        to_mpath("dir1/subdir1/subsubdir2/file_2") => Some("lions".to_string()),
    ];
    let hooks: HashMap<String, Box<dyn ChangesetHook>> = hashmap! {
        "hook1".to_string() => file_text_matching_changeset_hook(hook1_map),
        "hook2".to_string() => file_text_matching_changeset_hook(hook2_map),
        "hook3".to_string() => file_text_matching_changeset_hook(hook3_map),
    };
    let bookmarks = hashmap! {
        "bm1".to_string() => vec!["hook1".to_string(), "hook2".to_string()]
    };
    let regexes = hashmap! {
        "b.*".to_string() => vec!["hook2".to_string(), "hook3".to_string()]
    };
    let expected = hashmap! {
        "hook1".to_string() => HookExecution::Accepted,
        "hook2".to_string() => default_rejection(),
        "hook3".to_string() => default_rejection(),
    };
    run_changeset_hooks(ctx, "bm1", hooks, bookmarks, regexes, expected).await;
}

#[fbinit::test]
async fn test_changeset_hook_lengths(fb: FacebookInit) {
    let ctx = CoreContext::test_mock(fb);
    let hook1_map = hashmap![
        to_mpath("dir1/subdir1/subsubdir1/file_1") => 9,
        to_mpath("dir1/subdir1/subsubdir2/file_1") => 11,
        to_mpath("dir1/subdir1/subsubdir2/file_2") => 4
    ];
    let hook2_map = hashmap![
        to_mpath("dir1/subdir1/subsubdir1/file_1") => 9,
        to_mpath("dir1/subdir1/subsubdir2/file_1") => 12,
        to_mpath("dir1/subdir1/subsubdir2/file_2") => 4
    ];
    let hook3_map = hashmap![
        to_mpath("dir1/subdir1/subsubdir1/file_1") => 15,
        to_mpath("dir1/subdir1/subsubdir2/file_1") => 17,
        to_mpath("dir1/subdir1/subsubdir2/file_2") => 2
    ];
    let hooks: HashMap<String, Box<dyn ChangesetHook>> = hashmap! {
        "hook1".to_string() => length_matching_changeset_hook(hook1_map),
        "hook2".to_string() => length_matching_changeset_hook(hook2_map),
        "hook3".to_string() => length_matching_changeset_hook(hook3_map),
    };
    let bookmarks = hashmap! {
        "bm1".to_string() => vec!["hook1".to_string(), "hook2".to_string()],
    };
    let regexes = hashmap! {
        "b.*".to_string() => vec!["hook3".to_string()],
    };
    let expected = hashmap! {
        "hook1".to_string() => HookExecution::Accepted,
        "hook2".to_string() => default_rejection(),
        "hook3".to_string() => default_rejection(),
    };
    run_changeset_hooks(ctx, "bm1", hooks, bookmarks, regexes, expected).await;
}

#[fbinit::test]
async fn test_file_hook_accepted(fb: FacebookInit) {
    let ctx = CoreContext::test_mock(fb);
    let hooks: HashMap<String, Box<dyn FileHook>> = hashmap! {
        "hook1".to_string() => always_accepting_file_hook()
    };
    let bookmarks = hashmap! {
        "bm1".to_string() => vec!["hook1".to_string()]
    };
    let regexes = hashmap! {};
    let expected = hashmap! {
        "hook1".to_string() => hashmap! {
            "dir1/subdir1/subsubdir1/file_1".to_string() => HookExecution::Accepted,
            "dir1/subdir1/subsubdir2/file_1".to_string() => HookExecution::Accepted,
            "dir1/subdir1/subsubdir2/file_2".to_string() => HookExecution::Accepted,
        }
    };
    run_file_hooks(
        ctx,
        "bm1",
        hooks,
        bookmarks,
        regexes,
        expected,
        ContentFetcherType::InMemory,
    )
    .await;
}

#[fbinit::test]
async fn test_file_hook_rejected(fb: FacebookInit) {
    let ctx = CoreContext::test_mock(fb);
    let hooks: HashMap<String, Box<dyn FileHook>> = hashmap! {
        "hook1".to_string() => always_rejecting_file_hook()
    };
    let bookmarks = hashmap! {
        "bm1".to_string() => vec!["hook1".to_string()]
    };
    let regexes = hashmap! {};
    let expected = hashmap! {
        "hook1".to_string() => hashmap! {
            "dir1/subdir1/subsubdir1/file_1".to_string() => default_rejection(),
            "dir1/subdir1/subsubdir2/file_1".to_string() => default_rejection(),
            "dir1/subdir1/subsubdir2/file_2".to_string() => default_rejection(),
        }
    };
    run_file_hooks(
        ctx,
        "bm1",
        hooks,
        bookmarks,
        regexes,
        expected,
        ContentFetcherType::InMemory,
    )
    .await;
}

#[fbinit::test]
async fn test_file_hook_mix(fb: FacebookInit) {
    let ctx = CoreContext::test_mock(fb);
    let hooks: HashMap<String, Box<dyn FileHook>> = hashmap! {
        "hook1".to_string() => always_rejecting_file_hook(),
        "hook2".to_string() => always_accepting_file_hook()
    };
    let bookmarks = hashmap! {
        "bm1".to_string() => vec!["hook1".to_string()]
    };
    let regexes = hashmap! {
        "b.*".to_string() => vec!["hook2".to_string()]
    };
    let expected = hashmap! {
        "hook1".to_string() => hashmap! {
            "dir1/subdir1/subsubdir1/file_1".to_string() => default_rejection(),
            "dir1/subdir1/subsubdir2/file_1".to_string() => default_rejection(),
            "dir1/subdir1/subsubdir2/file_2".to_string() => default_rejection(),
        },
        "hook2".to_string() => hashmap! {
            "dir1/subdir1/subsubdir1/file_1".to_string() => HookExecution::Accepted,
            "dir1/subdir1/subsubdir2/file_1".to_string() => HookExecution::Accepted,
            "dir1/subdir1/subsubdir2/file_2".to_string() => HookExecution::Accepted,
        }
    };
    run_file_hooks(
        ctx,
        "bm1",
        hooks,
        bookmarks,
        regexes,
        expected,
        ContentFetcherType::InMemory,
    )
    .await;
}

#[fbinit::test]
async fn test_file_hooks_paths(fb: FacebookInit) {
    let ctx = CoreContext::test_mock(fb);
    let matching_paths = hashset![
        to_mpath("dir1/subdir1/subsubdir2/file_1"),
        to_mpath("dir1/subdir1/subsubdir2/file_2"),
    ];
    let hooks: HashMap<String, Box<dyn FileHook>> = hashmap! {
        "hook1".to_string() => path_matching_file_hook(matching_paths),
    };
    let bookmarks = hashmap! {
        "bm1".to_string() => vec!["hook1".to_string()]
    };
    let regexes = hashmap! {};
    let expected = hashmap! {
        "hook1".to_string() => hashmap! {
            "dir1/subdir1/subsubdir1/file_1".to_string() => default_rejection(),
            "dir1/subdir1/subsubdir2/file_1".to_string() => HookExecution::Accepted,
            "dir1/subdir1/subsubdir2/file_2".to_string() => HookExecution::Accepted,
        }
    };
    run_file_hooks(
        ctx,
        "bm1",
        hooks,
        bookmarks,
        regexes,
        expected,
        ContentFetcherType::InMemory,
    )
    .await;
}

#[fbinit::test]
async fn test_file_hooks_paths_mix(fb: FacebookInit) {
    let ctx = CoreContext::test_mock(fb);
    let matching_paths1 = hashset![
        to_mpath("dir1/subdir1/subsubdir2/file_1"),
        to_mpath("dir1/subdir1/subsubdir2/file_2"),
    ];
    let matching_paths2 = hashset![to_mpath("dir1/subdir1/subsubdir1/file_1"),];
    let hooks: HashMap<String, Box<dyn FileHook>> = hashmap! {
        "hook1".to_string() => path_matching_file_hook(matching_paths1),
        "hook2".to_string() => path_matching_file_hook(matching_paths2),
    };
    let bookmarks = hashmap! {
        "bm1".to_string() => vec!["hook1".to_string()]
    };
    let regexes = hashmap! {
        "b.*".to_string() => vec!["hook2".to_string()]
    };
    let expected = hashmap! {
        "hook1".to_string() => hashmap! {
            "dir1/subdir1/subsubdir1/file_1".to_string() => default_rejection(),
            "dir1/subdir1/subsubdir2/file_1".to_string() => HookExecution::Accepted,
            "dir1/subdir1/subsubdir2/file_2".to_string() => HookExecution::Accepted,
        },
        "hook2".to_string() => hashmap! {
            "dir1/subdir1/subsubdir1/file_1".to_string() => HookExecution::Accepted,
            "dir1/subdir1/subsubdir2/file_1".to_string() => default_rejection(),
            "dir1/subdir1/subsubdir2/file_2".to_string() => default_rejection(),
        }
    };
    run_file_hooks(
        ctx,
        "bm1",
        hooks,
        bookmarks,
        regexes,
        expected,
        ContentFetcherType::InMemory,
    )
    .await;
}

#[fbinit::test]
async fn test_file_hook_file_text(fb: FacebookInit) {
    let ctx = CoreContext::test_mock(fb);
    let hooks: HashMap<String, Box<dyn FileHook>> = hashmap! {
        "hook1".to_string() => file_text_matching_file_hook(Some("elephants".to_string())),
        "hook2".to_string() => file_text_matching_file_hook(Some("hippopatami".to_string())),
        "hook3".to_string() => file_text_matching_file_hook(Some("eels".to_string()))
    };
    let bookmarks = hashmap! {
        "bm1".to_string() => vec!["hook1".to_string(), "hook2".to_string()],
    };
    let regexes = hashmap! {
        "b.*".to_string() => vec!["hook3".to_string()],
    };
    let expected = hashmap! {
        "hook1".to_string() => hashmap! {
            "dir1/subdir1/subsubdir1/file_1".to_string() => HookExecution::Accepted,
            "dir1/subdir1/subsubdir2/file_1".to_string() => default_rejection(),
            "dir1/subdir1/subsubdir2/file_2".to_string() => default_rejection(),
        },
        "hook2".to_string() => hashmap! {
            "dir1/subdir1/subsubdir1/file_1".to_string() => default_rejection(),
            "dir1/subdir1/subsubdir2/file_1".to_string() => HookExecution::Accepted,
            "dir1/subdir1/subsubdir2/file_2".to_string() => default_rejection(),
        },
        "hook3".to_string() => hashmap! {
            "dir1/subdir1/subsubdir1/file_1".to_string() => default_rejection(),
            "dir1/subdir1/subsubdir2/file_1".to_string() => default_rejection(),
            "dir1/subdir1/subsubdir2/file_2".to_string() => HookExecution::Accepted,
        },
    };
    run_file_hooks(
        ctx,
        "bm1",
        hooks,
        bookmarks,
        regexes,
        expected,
        ContentFetcherType::InMemory,
    )
    .await;
}

#[fbinit::test]
async fn test_file_hook_is_symlink(fb: FacebookInit) {
    let ctx = CoreContext::test_mock(fb);
    let hooks: HashMap<String, Box<dyn FileHook>> = hashmap! {
        "hook1".to_string() => is_symlink_matching_file_hook(true),
        "hook2".to_string() => is_symlink_matching_file_hook(false),
    };
    let bookmarks = hashmap! {
        "bm1".to_string() => vec!["hook1".to_string()],
    };
    let regexes = hashmap! {
        "b.*".to_string() => vec!["hook2".to_string()],
    };
    let expected = hashmap! {
        "hook1".to_string() => hashmap! {
            "dir1/subdir1/subsubdir1/file_1".to_string() => HookExecution::Accepted,
            "dir1/subdir1/subsubdir2/file_1".to_string() => default_rejection(),
            "dir1/subdir1/subsubdir2/file_2".to_string() => default_rejection(),
        },
        "hook2".to_string() => hashmap! {
            "dir1/subdir1/subsubdir1/file_1".to_string() => default_rejection(),
            "dir1/subdir1/subsubdir2/file_1".to_string() => HookExecution::Accepted,
            "dir1/subdir1/subsubdir2/file_2".to_string() => HookExecution::Accepted,
        },
    };
    run_file_hooks(
        ctx,
        "bm1",
        hooks,
        bookmarks,
        regexes,
        expected,
        ContentFetcherType::InMemory,
    )
    .await;
}

#[fbinit::test]
async fn test_file_hook_length(fb: FacebookInit) {
    let ctx = CoreContext::test_mock(fb);
    let hooks: HashMap<String, Box<dyn FileHook>> = hashmap! {
        "hook1".to_string() => length_matching_file_hook("elephants".len() as u64),
        "hook2".to_string() => length_matching_file_hook("hippopatami".len() as u64),
        "hook3".to_string() => length_matching_file_hook("eels".len() as u64),
        "hook4".to_string() => length_matching_file_hook(999)
    };
    let bookmarks = hashmap! {
        "bm1".to_string() => vec!["hook1".to_string(), "hook2".to_string(), "hook3".to_string()],
    };
    let regexes = hashmap! {
        "b.*".to_string() => vec!["hook3".to_string(), "hook4".to_string()],
    };
    let expected = hashmap! {
        "hook1".to_string() => hashmap! {
            "dir1/subdir1/subsubdir1/file_1".to_string() => HookExecution::Accepted,
            "dir1/subdir1/subsubdir2/file_1".to_string() => default_rejection(),
            "dir1/subdir1/subsubdir2/file_2".to_string() => default_rejection(),
        },
        "hook2".to_string() => hashmap! {
            "dir1/subdir1/subsubdir1/file_1".to_string() => default_rejection(),
            "dir1/subdir1/subsubdir2/file_1".to_string() => HookExecution::Accepted,
            "dir1/subdir1/subsubdir2/file_2".to_string() => default_rejection(),
        },
        "hook3".to_string() => hashmap! {
            "dir1/subdir1/subsubdir1/file_1".to_string() => default_rejection(),
            "dir1/subdir1/subsubdir2/file_1".to_string() => default_rejection(),
            "dir1/subdir1/subsubdir2/file_2".to_string() => HookExecution::Accepted,
        },
        "hook4".to_string() => hashmap! {
            "dir1/subdir1/subsubdir1/file_1".to_string() => default_rejection(),
            "dir1/subdir1/subsubdir2/file_1".to_string() => default_rejection(),
            "dir1/subdir1/subsubdir2/file_2".to_string() => default_rejection(),
        },
    };
    run_file_hooks(
        ctx,
        "bm1",
        hooks,
        bookmarks,
        regexes,
        expected,
        ContentFetcherType::InMemory,
    )
    .await;
}

#[fbinit::test]
async fn test_cs_find_content_hook_with_blob_store(fb: FacebookInit) -> Result<(), Error> {
    let ctx = CoreContext::test_mock(fb);
    let repo: BasicTestRepo = test_repo_factory::build_empty(ctx.fb).await?;
    let root_id = CreateCommitContext::new_root(&ctx, &repo)
        .add_file("dir/file", "dir/file")
        .add_file("dir-2/file", "dir-2/file")
        .commit()
        .await?;
    let bcs_id = CreateCommitContext::new(&ctx, &repo, vec![root_id])
        .add_file("dir/sub/file", "dir/sub/file")
        .add_file("dir-2", "dir-2 is a file now")
        .commit()
        .await?;

    // find simple file
    let hook_name1 = "hook1".to_string();
    let hook1 = Box::new(FindFilesChangesetHook {
        filename: "dir/sub/file".to_string(),
    });

    // find non-existent file
    let hook_name2 = "hook2".to_string();
    let hook2 = Box::new(FindFilesChangesetHook {
        filename: "dir-2/file".to_string(),
    });

    // run first hook on a repo without master bookmark
    // the hook should reject the commit
    let hooks: HashMap<String, Box<dyn ChangesetHook>> = hashmap! {
        hook_name1.clone() => hook1.clone() as Box<dyn ChangesetHook>,
    };
    let bookmarks = hashmap! {
        "bm1".to_string() => vec![hook_name1.clone()]
    };
    let regexes = hashmap! {};
    let expected = hashmap! {
        hook_name1.clone() => HookExecution::Rejected(HookRejectionInfo::new("no master bookmark found")),
    };

    run_changeset_hooks_with_mgr(
        ctx.clone(),
        None,
        "bm1",
        hooks,
        bookmarks,
        regexes.clone(),
        expected,
        ContentFetcherType::Blob(repo.clone()),
    )
    .await;

    // set master bookmark
    let mut txn = repo.bookmarks().create_transaction(ctx.clone());
    txn.force_set(
        &BookmarkKey::new("master")?,
        bcs_id,
        BookmarkUpdateReason::TestMove,
    )?;
    txn.commit().await?;

    // run hooks again
    let hooks: HashMap<String, Box<dyn ChangesetHook>> = hashmap! {
        hook_name1.clone() => hook1 as Box<dyn ChangesetHook>,
        hook_name2.clone() => hook2 as Box<dyn ChangesetHook>,
    };
    let bookmarks = hashmap! {
        "bm1".to_string() => vec![hook_name1.clone(), hook_name2.clone()]
    };
    let regexes = hashmap! {};
    let expected = hashmap! {
        hook_name1 => HookExecution::Accepted,
        hook_name2 => HookExecution::Rejected(HookRejectionInfo::new("there is no such file")),
    };
    run_changeset_hooks_with_mgr(
        ctx.clone(),
        None,
        "bm1",
        hooks,
        bookmarks,
        regexes.clone(),
        expected,
        ContentFetcherType::Blob(repo),
    )
    .await;

    Ok(())
}

#[fbinit::test]
async fn test_cs_file_changes_hook_with_blob_store(fb: FacebookInit) -> Result<(), Error> {
    let ctx = CoreContext::test_mock(fb);
    let repo: BasicTestRepo = test_repo_factory::build_empty(ctx.fb).await?;
    let root_id = CreateCommitContext::new_root(&ctx, &repo)
        .add_file("file", "file")
        .add_file("dir/file", "dir/file")
        .add_file("dir/sub/file", "dir/sub/file")
        .add_file("dir-2/file", "dir-2/file")
        .commit()
        .await?;
    // set master bookmark
    bookmark(&ctx, &repo, "master").set_to(root_id).await?;

    let bcs_id = CreateCommitContext::new(&ctx, &repo, vec![root_id])
        .delete_file("file")
        .add_file("dir", "dir to file")
        .add_file("dir-2/file", "updated dir-2/file")
        .add_file("dir-3/sub/file-1", "dir-3/sub/file-1")
        .add_file("dir-3/sub/file-2", "dir-3/sub/file-2")
        .commit()
        .await?;
    let changeset = bcs_id.load(&ctx, repo.repo_blobstore()).await?;

    let hook_name = "hook".to_string();
    let hook = Box::new(FileChangesChangesetHook {
        added: 3,
        changed: 1,
        removed: 3,
    });

    let hooks: HashMap<String, Box<dyn ChangesetHook>> = hashmap! {
        hook_name.clone() => hook as Box<dyn ChangesetHook>,
    };
    let bookmarks = hashmap! {
        "bm1".to_string() => vec![hook_name.clone()]
    };
    let regexes = hashmap! {};
    let expected = hashmap! {
        hook_name => HookExecution::Accepted,
    };
    run_changeset_hooks_with_mgr(
        ctx.clone(),
        Some(changeset),
        "bm1",
        hooks,
        bookmarks,
        regexes.clone(),
        expected,
        ContentFetcherType::Blob(repo),
    )
    .await;

    Ok(())
}

#[fbinit::test]
async fn test_cs_latest_changes_hook_with_blob_store(fb: FacebookInit) -> Result<(), Error> {
    let ctx = CoreContext::test_mock(fb);
    let repo: BasicTestRepo = test_repo_factory::build_empty(ctx.fb).await?;
    let root_id = CreateCommitContext::new_root(&ctx, &repo)
        .add_file("file", "file")
        .commit()
        .await?;
    // set master bookmark
    bookmark(&ctx, &repo, "master").set_to(root_id).await?;

    let hook_name = "hook".to_string();
    let expected = hashmap! { to_mpath("file") => Some(root_id), to_mpath("non_existent") => None };
    let hook = Box::new(LatestChangesChangesetHook(expected));

    let hooks: HashMap<String, Box<dyn ChangesetHook>> = hashmap! {
        hook_name.clone() => hook as Box<dyn ChangesetHook>,
    };
    let bookmarks = hashmap! {
        "bm1".to_string() => vec![hook_name.clone()]
    };
    let regexes = hashmap! {};
    let expected = hashmap! {
        hook_name => HookExecution::Accepted,
    };
    run_changeset_hooks_with_mgr(
        ctx.clone(),
        None,
        "bm1",
        hooks,
        bookmarks,
        regexes.clone(),
        expected,
        ContentFetcherType::Blob(repo),
    )
    .await;

    Ok(())
}

#[fbinit::test]
async fn test_cs_hooks_with_blob_store(fb: FacebookInit) {
    let ctx = CoreContext::test_mock(fb);
    let hooks: HashMap<String, Box<dyn ChangesetHook>> = hashmap! {
        "hook1".to_string() => always_accepting_changeset_hook()
    };
    let bookmarks = hashmap! {
        "bm1".to_string() => vec!["hook1".to_string()]
    };
    let regexes = hashmap! {};
    let expected = hashmap! {
        "hook1".to_string() => HookExecution::Accepted
    };
    run_changeset_hooks_with_mgr(
        ctx.clone(),
        None,
        "bm1",
        hooks,
        bookmarks,
        regexes.clone(),
        expected,
        ContentFetcherType::Blob(fixtures::ManyFilesDirs::get_test_repo(ctx.fb).await),
    )
    .await;
}

#[fbinit::test]
async fn test_file_hooks_with_blob_store(fb: FacebookInit) {
    let ctx = CoreContext::test_mock(fb);
    // Create an init a repo
    let (repo, bcs_id) = {
        let repo: BasicTestRepo = test_repo_factory::build_empty(fb).await.unwrap();

        let parent = create_commit(
            ctx.clone(),
            repo.clone(),
            vec![],
            store_files(&ctx, btreemap! {"toremove" => Some("content")}, &repo).await,
        )
        .await;
        let bcs_id = create_commit(
            ctx.clone(),
            repo.clone(),
            vec![parent],
            store_files(
                &ctx,
                btreemap! {
                    "toremove" => None,
                    "newfile" => Some("newcontent"),
                    "dir/somefile" => Some("good"),
                },
                &repo,
            )
            .await,
        )
        .await;

        let mut txn = repo.bookmarks().create_transaction(ctx.clone());
        txn.force_set(
            &BookmarkKey::new("master").unwrap(),
            bcs_id,
            BookmarkUpdateReason::TestMove,
        )
        .unwrap();
        txn.commit().await.unwrap();
        (repo, bcs_id)
    };

    let bookmarks = hashmap! {
        "master".to_string() => vec!["hook1".to_string()]
    };
    let hooks: HashMap<String, Box<dyn FileHook>> = hashmap! {
        "hook1".to_string() => length_matching_file_hook(4),
    };
    let regexes = hashmap! {};

    let expected = hashmap! {
        "hook1".to_string() => hashmap! {
            "newfile".to_string() => default_rejection(),
            "dir/somefile".to_string() => HookExecution::Accepted,
            "toremove".to_string() => HookExecution::Accepted,
        },
    };

    let bcs = bcs_id
        .load(&ctx, repo.repo_blobstore())
        .await
        .expect("Can't load commit");
    run_file_hooks_for_cs(
        ctx,
        "master",
        hooks,
        bookmarks,
        regexes,
        expected,
        ContentFetcherType::Blob(repo),
        bcs,
    )
    .await;
}

async fn run_changeset_hooks(
    ctx: CoreContext,
    bookmark_name: &str,
    hooks: HashMap<String, Box<dyn ChangesetHook>>,
    bookmarks: HashMap<String, Vec<String>>,
    regexes: HashMap<String, Vec<String>>,
    expected: HashMap<String, HookExecution>,
) {
    run_changeset_hooks_with_mgr(
        ctx,
        None,
        bookmark_name,
        hooks,
        bookmarks,
        regexes,
        expected,
        ContentFetcherType::InMemory,
    )
    .await
}

async fn run_changeset_hooks_with_mgr(
    ctx: CoreContext,
    changeset: Option<BonsaiChangeset>,
    bookmark_name: &str,
    hooks: HashMap<String, Box<dyn ChangesetHook>>,
    bookmarks: HashMap<String, Vec<String>>,
    regexes: HashMap<String, Vec<String>>,
    expected: HashMap<String, HookExecution>,
    content_manager_type: ContentFetcherType,
) {
    let mut hook_manager =
        setup_hook_manager(ctx.fb, bookmarks, regexes, content_manager_type).await;
    for (hook_name, hook) in hooks {
        hook_manager.register_changeset_hook(&hook_name, hook, Default::default());
    }

    let changeset = changeset.unwrap_or_else(default_changeset);
    let res = hook_manager
        .run_hooks_for_bookmark(
            &ctx,
            vec![changeset].iter(),
            &BookmarkKey::new(bookmark_name).unwrap(),
            None,
            CrossRepoPushSource::NativeToThisRepo,
            PushAuthoredBy::User,
        )
        .await
        .unwrap();
    let map: HashMap<String, HookExecution> = res
        .into_iter()
        .map(|outcome| (outcome.get_hook_name().to_string(), outcome.into()))
        .collect();
    assert_eq!(expected, map);
}

enum ContentFetcherType {
    InMemory,
    Blob(BasicTestRepo),
}

async fn run_file_hooks(
    ctx: CoreContext,
    bookmark_name: &str,
    hooks: HashMap<String, Box<dyn FileHook>>,
    bookmarks: HashMap<String, Vec<String>>,
    regexes: HashMap<String, Vec<String>>,
    expected: HashMap<String, HashMap<String, HookExecution>>,
    content_manager_type: ContentFetcherType,
) {
    run_file_hooks_with_mgr(
        ctx,
        bookmark_name,
        hooks,
        bookmarks,
        regexes,
        expected,
        content_manager_type,
        default_changeset(),
    )
    .await
}

async fn run_file_hooks_for_cs(
    ctx: CoreContext,
    bookmark_name: &str,
    hooks: HashMap<String, Box<dyn FileHook>>,
    bookmarks: HashMap<String, Vec<String>>,
    regexes: HashMap<String, Vec<String>>,
    expected: HashMap<String, HashMap<String, HookExecution>>,
    content_manager_type: ContentFetcherType,
    cs: BonsaiChangeset,
) {
    run_file_hooks_with_mgr(
        ctx,
        bookmark_name,
        hooks,
        bookmarks,
        regexes,
        expected,
        content_manager_type,
        cs,
    )
    .await
}

async fn run_file_hooks_with_mgr(
    ctx: CoreContext,
    bookmark_name: &str,
    hooks: HashMap<String, Box<dyn FileHook>>,
    bookmarks: HashMap<String, Vec<String>>,
    regexes: HashMap<String, Vec<String>>,
    expected: HashMap<String, HashMap<String, HookExecution>>,
    content_manager_type: ContentFetcherType,
    cs: BonsaiChangeset,
) {
    let mut hook_manager =
        setup_hook_manager(ctx.fb, bookmarks, regexes, content_manager_type).await;
    for (hook_name, hook) in hooks {
        hook_manager.register_file_hook(&hook_name, hook, Default::default());
    }
    let res = hook_manager
        .run_hooks_for_bookmark(
            &ctx,
            vec![cs].iter(),
            &BookmarkKey::new(bookmark_name).unwrap(),
            None,
            CrossRepoPushSource::NativeToThisRepo,
            PushAuthoredBy::User,
        )
        .await
        .unwrap();
    let map: HashMap<String, HashMap<String, HookExecution>> =
        res.into_iter().fold(HashMap::new(), |mut m, outcome| {
            let path = outcome.get_file_path().expect("Changeset hook").to_string();
            match m.entry(outcome.get_hook_name().to_string()) {
                Entry::Vacant(v) => v.insert(HashMap::new()).insert(path, outcome.into()),
                Entry::Occupied(mut v) => v.get_mut().insert(path, outcome.into()),
            };
            m
        });
    assert_eq!(expected, map);
}

async fn setup_hook_manager(
    fb: FacebookInit,
    bookmarks: HashMap<String, Vec<String>>,
    regexes: HashMap<String, Vec<String>>,
    content_manager_type: ContentFetcherType,
) -> HookManager {
    let mut hook_manager = match content_manager_type {
        ContentFetcherType::InMemory => hook_manager_inmem(fb).await,
        ContentFetcherType::Blob(repo) => hook_manager_repo(fb, &repo).await,
    };
    for (bookmark_name, hook_names) in bookmarks {
        hook_manager
            .set_hooks_for_bookmark(BookmarkKey::new(bookmark_name).unwrap().into(), hook_names);
    }
    for (regx, hook_names) in regexes {
        hook_manager.set_hooks_for_bookmark(Regex::new(&regx).unwrap().into(), hook_names);
    }
    hook_manager
}

fn default_rejection() -> HookExecution {
    HookExecution::Rejected(HookRejectionInfo::new_long("desc", "long_desc".to_string()))
}

fn default_changeset() -> BonsaiChangeset {
    BonsaiChangesetMut {
        parents: Vec::new(),
        author: "Jeremy Fitzhardinge <jsgf@fb.com>".to_string(),
        author_date: DateTime::from_timestamp(1584887580, 0).expect("Getting timestamp"),
        committer: None,
        committer_date: None,
        message: "This is a commit message".to_string(),
        hg_extra: Default::default(),
        git_extra_headers: None,
        git_tree_hash: None,
        file_changes: sorted_vector_map!{
            to_mpath("dir1/subdir1/subsubdir1/file_1") => FileChange::tracked(ONES_CTID, FileType::Symlink, 15, None),
            to_mpath("dir1/subdir1/subsubdir2/file_1") => FileChange::tracked(TWOS_CTID, FileType::Regular, 17, None),
            to_mpath("dir1/subdir1/subsubdir2/file_2") => FileChange::tracked(THREES_CTID, FileType::Regular, 2, None),
        },
        is_snapshot: false,
        git_annotated_tag: None,
    }.freeze().expect("Created changeset")
}

async fn hook_manager_repo(fb: FacebookInit, repo: &BasicTestRepo) -> HookManager {
    let ctx = CoreContext::test_mock(fb);

    let content_manager = RepoFileContentManager::new(&repo);
    HookManager::new(
        ctx.fb,
        DefaultAclProvider::new(fb).as_ref(),
        Box::new(content_manager),
        HookManagerParams {
            disable_acl_checker: true,
            ..Default::default()
        },
        MononokeScubaSampleBuilder::with_discard(),
        "zoo".to_string(),
    )
    .await
    .expect("Failed to construct HookManager")
}

async fn hook_manager_many_files_dirs_repo(fb: FacebookInit) -> HookManager {
    hook_manager_repo(fb, &fixtures::ManyFilesDirs::get_test_repo(fb).await).await
}

fn to_mpath(string: &str) -> MPath {
    MPath::new(string).unwrap()
}

async fn hook_manager_inmem(fb: FacebookInit) -> HookManager {
    let ctx = CoreContext::test_mock(fb);

    let mut content_manager = InMemoryFileContentManager::new();
    content_manager.insert(ONES_CTID, "elephants");
    content_manager.insert(TWOS_CTID, "hippopatami");
    content_manager.insert(THREES_CTID, "eels");

    HookManager::new(
        ctx.fb,
        DefaultAclProvider::new(fb).as_ref(),
        Box::new(content_manager),
        HookManagerParams {
            disable_acl_checker: true,
            ..Default::default()
        },
        MononokeScubaSampleBuilder::with_discard(),
        "zoo".to_string(),
    )
    .await
    .expect("Failed to construct HookManager")
}

#[fbinit::test]
async fn test_load_hooks_bad_rust_hook(fb: FacebookInit) {
    let mut config = RepoConfig::default();
    config.bookmarks = vec![BookmarkParams {
        bookmark: BookmarkKey::new("bm1").unwrap().into(),
        hooks: vec!["hook1".into()],
        only_fast_forward: false,
        allowed_users: None,
        allowed_hipster_group: None,
        rewrite_dates: None,
        hooks_skip_ancestors_of: vec![],
        ensure_ancestor_of: None,
        allow_move_to_public_commits_without_hooks: false,
    }];

    config.hooks = vec![HookParams {
        name: "hook1".into(),
        config: Default::default(),
    }];

    let mut hm = hook_manager_many_files_dirs_repo(fb).await;

    match load_hooks(
        fb,
        DefaultAclProvider::new(fb).as_ref(),
        &mut hm,
        &config,
        &hashset![],
    )
    .await
    .unwrap_err()
    .downcast::<ErrorKind>()
    {
        Ok(ErrorKind::InvalidRustHook(hook_name)) => {
            assert_eq!(hook_name, "hook1".to_string());
        }
        _ => panic!("Unexpected err type"),
    };
}

#[fbinit::test]
async fn test_load_disabled_hooks(fb: FacebookInit) {
    let mut config = RepoConfig::default();

    config.hooks = vec![HookParams {
        name: "hook1".into(),
        config: Default::default(),
    }];

    let mut hm = hook_manager_many_files_dirs_repo(fb).await;

    load_hooks(
        fb,
        DefaultAclProvider::new(fb).as_ref(),
        &mut hm,
        &config,
        &hashset!["hook1".to_string()],
    )
    .await
    .expect("disabling a broken hook should allow loading to succeed");
}

#[fbinit::test]
async fn test_load_disabled_hooks_referenced_by_bookmark(fb: FacebookInit) {
    let mut config = RepoConfig::default();

    config.bookmarks = vec![BookmarkParams {
        bookmark: BookmarkKey::new("bm1").unwrap().into(),
        hooks: vec!["hook1".into()],
        only_fast_forward: false,
        allowed_users: None,
        allowed_hipster_group: None,
        rewrite_dates: None,
        hooks_skip_ancestors_of: vec![],
        ensure_ancestor_of: None,
        allow_move_to_public_commits_without_hooks: false,
    }];

    config.hooks = vec![HookParams {
        name: "hook1".into(),
        config: Default::default(),
    }];

    let mut hm = hook_manager_many_files_dirs_repo(fb).await;

    load_hooks(
        fb,
        DefaultAclProvider::new(fb).as_ref(),
        &mut hm,
        &config,
        &hashset!["hook1".to_string()],
    )
    .await
    .expect("disabling a broken hook should allow loading to succeed");
}

#[fbinit::test]
async fn test_load_disabled_hooks_hook_does_not_exist(fb: FacebookInit) {
    let config = RepoConfig::default();
    let mut hm = hook_manager_many_files_dirs_repo(fb).await;

    match load_hooks(
        fb,
        DefaultAclProvider::new(fb).as_ref(),
        &mut hm,
        &config,
        &hashset!["hook1".to_string()],
    )
    .await
    .unwrap_err()
    .downcast::<ErrorKind>()
    {
        Ok(ErrorKind::NoSuchHookToDisable(hooks)) => {
            assert_eq!(hashset!["hook1".to_string()], hooks);
        }
        _ => panic!("Unexpected err type"),
    };
}


/*
 * Copyright (c) Meta Platforms, Inc. and affiliates.
 *
 * This software may be used and distributed according to the terms of the
 * GNU General Public License version 2.
 */

use std::collections::HashSet;

pub use mercurial_types::HgChangesetId;
use metaconfig_types::BookmarkOrRegex;
pub use mononoke_types::MPath;
use thiserror::Error;

#[derive(Debug, Error)]
pub enum ErrorKind {
    #[error("No such hook '{0}'")]
    NoSuchHook(String),

    #[error("Error while parsing hook '{0}'")]
    HookParseError(String),
    #[error("Error while running hook '{0}'")]
    HookRuntimeError(String),

    #[error("invalid file structure: {0}")]
    InvalidFileStructure(String),
    #[error("invalid path: {0}")]
    InvalidPath(MPath),

    #[error("Missing file for cs '{0}' path '{1}'")]
    MissingFile(HgChangesetId, MPath),

    #[error("Hook(s) referenced in bookmark {0:#?} do not exist: {1:?}")]
    NoSuchBookmarkHook(BookmarkOrRegex, HashSet<String>),

    #[error("invalid rust hook: {0}")]
    InvalidRustHook(String),

    #[error("Disabled hook(s) do(es) not exist: {0:?}")]
    NoSuchHookToDisable(HashSet<String>),
}


/*
 * Copyright (c) Meta Platforms, Inc. and affiliates.
 *
 * This software may be used and distributed according to the terms of the
 * GNU General Public License version 2.
 */

//! This sub module contains functions to load hooks for the server

use std::collections::HashSet;

use anyhow::Error;
use fbinit::FacebookInit;
use metaconfig_types::RepoConfig;
use permission_checker::AclProvider;

use crate::errors::*;
#[cfg(fbcode_build)]
use crate::facebook::rust_hooks::hook_name_to_changeset_hook;
#[cfg(fbcode_build)]
use crate::facebook::rust_hooks::hook_name_to_file_hook;
#[cfg(not(fbcode_build))]
use crate::rust_hooks::hook_name_to_changeset_hook;
#[cfg(not(fbcode_build))]
use crate::rust_hooks::hook_name_to_file_hook;
use crate::ChangesetHook;
use crate::FileHook;
use crate::HookManager;

enum LoadedRustHook {
    ChangesetHook(Box<dyn ChangesetHook>),
    FileHook(Box<dyn FileHook>),
}

pub async fn load_hooks(
    fb: FacebookInit,
    acl_provider: &dyn AclProvider,
    hook_manager: &mut HookManager,
    config: &RepoConfig,
    disabled_hooks: &HashSet<String>,
) -> Result<(), Error> {
    let mut hooks_not_disabled = disabled_hooks.clone();

    let mut hook_set = HashSet::new();
    for hook in config.hooks.clone() {
        use LoadedRustHook::*;

        if disabled_hooks.contains(&hook.name) {
            hooks_not_disabled.remove(&hook.name);
            continue;
        }

        let rust_hook = {
            if let Some(hook) = hook_name_to_changeset_hook(
                fb,
                &hook.name,
                &hook.config,
                acl_provider,
                hook_manager.get_reviewers_perm_checker(),
                hook_manager.repo_name(),
            )
            .await?
            {
                ChangesetHook(hook)
            } else if let Some(hook) = hook_name_to_file_hook(fb, &hook.name, &hook.config)? {
                FileHook(hook)
            } else {
                return Err(ErrorKind::InvalidRustHook(hook.name.clone()).into());
            }
        };

        match rust_hook {
            FileHook(rust_hook) => {
                hook_manager.register_file_hook(&hook.name, rust_hook, hook.config)
            }
            ChangesetHook(rust_hook) => {
                hook_manager.register_changeset_hook(&hook.name, rust_hook, hook.config)
            }
        }

        hook_set.insert(hook.name.clone());
    }

    if !hooks_not_disabled.is_empty() {
        return Err(ErrorKind::NoSuchHookToDisable(hooks_not_disabled).into());
    }

    for bookmark_hook in config.bookmarks.clone() {
        let bookmark = bookmark_hook.bookmark;
        let hooks: Vec<_> = bookmark_hook
            .hooks
            .into_iter()
            .filter(|h| !disabled_hooks.contains(h))
            .collect();
        let bm_hook_set: HashSet<String> = hooks.clone().into_iter().collect();
        let diff: HashSet<_> = bm_hook_set.difference(&hook_set).collect();
        if !diff.is_empty() {
            return Err(ErrorKind::NoSuchBookmarkHook(
                bookmark,
                diff.into_iter().cloned().collect(),
            )
            .into());
        } else {
            hook_manager.set_hooks_for_bookmark(bookmark, hooks);
        }
    }

    Ok(())
}


/*
 * Copyright (c) Meta Platforms, Inc. and affiliates.
 *
 * This software may be used and distributed according to the terms of the
 * GNU General Public License version 2.
 */

//! This crate contains the core structs and traits that implement the hook subsystem in
//! Mononoke.
//! Hooks are user defined pieces of code, typically written in a scripting language that
//! can be run at different stages of the process of rebasing user changes into a server side
//! bookmark.
//! The scripting language specific implementation of hooks are in the corresponding sub module.

#![cfg_attr(not(fbcode_build), allow(unused_crate_dependencies))]

pub mod errors;
#[cfg(fbcode_build)]
mod facebook;
pub mod hook_loader;
mod rust_hooks;

use std::borrow::Cow;
use std::collections::HashMap;
use std::fmt;
use std::hash::Hash;
use std::str;

use anyhow::Error;
use anyhow::Result;
use async_trait::async_trait;
use bookmarks::BookmarkKey;
use bytes::Bytes;
use context::CoreContext;
pub use errors::*;
use fbinit::FacebookInit;
use futures::stream::futures_unordered::FuturesUnordered;
use futures::stream::TryStreamExt;
use futures::try_join;
use futures::Future;
use futures::TryFutureExt;
use futures_stats::TimedFutureExt;
pub use hooks_content_stores::FileContentManager;
pub use hooks_content_stores::PathContent;
use metaconfig_types::BookmarkOrRegex;
use metaconfig_types::HookBypass;
use metaconfig_types::HookConfig;
use metaconfig_types::HookManagerParams;
use mononoke_types::BasicFileChange;
use mononoke_types::BonsaiChangeset;
use mononoke_types::ChangesetId;
use mononoke_types::MPath;
use permission_checker::AclProvider;
use permission_checker::ArcMembershipChecker;
use permission_checker::NeverMember;
use regex::Regex;
use scuba::builder::ServerData;
use scuba_ext::MononokeScubaSampleBuilder;
use slog::debug;

/// Manages hooks and allows them to be installed and uninstalled given a name
/// Knows how to run hooks

#[facet::facet]
pub struct HookManager {
    repo_name: String,
    hooks: HashMap<String, Hook>,
    bookmark_hooks: HashMap<BookmarkKey, Vec<String>>,
    regex_hooks: Vec<(Regex, Vec<String>)>,
    content_manager: Box<dyn FileContentManager>,
    reviewers_membership: ArcMembershipChecker,
    admin_membership: ArcMembershipChecker,
    scuba: MononokeScubaSampleBuilder,
    all_hooks_bypassed: bool,
    scuba_bypassed_commits: MononokeScubaSampleBuilder,
}

impl HookManager {
    pub async fn new(
        fb: FacebookInit,
        acl_provider: &dyn AclProvider,
        content_manager: Box<dyn FileContentManager>,
        hook_manager_params: HookManagerParams,
        mut scuba: MononokeScubaSampleBuilder,
        repo_name: String,
    ) -> Result<HookManager> {
        let hooks = HashMap::new();

        scuba
            .add("driver", "mononoke")
            .add("scm", "hg")
            .add_mapped_common_server_data(|data| match data {
                ServerData::Hostname => "host",
                _ => data.default_key(),
            });

        let (reviewers_membership, admin_membership) = if hook_manager_params.disable_acl_checker {
            (NeverMember::new(), NeverMember::new())
        } else {
            try_join!(acl_provider.reviewers_group(), acl_provider.admin_group())?
        };

        let scuba_bypassed_commits: MononokeScubaSampleBuilder =
            scuba_ext::MononokeScubaSampleBuilder::with_opt_table(
                fb,
                hook_manager_params.bypassed_commits_scuba_table,
            )?;

        Ok(HookManager {
            repo_name,
            hooks,
            bookmark_hooks: HashMap::new(),
            regex_hooks: Vec::new(),
            content_manager,
            reviewers_membership: reviewers_membership.into(),
            admin_membership: admin_membership.into(),
            scuba,
            all_hooks_bypassed: hook_manager_params.all_hooks_bypassed,
            scuba_bypassed_commits,
        })
    }

    // Create a very simple HookManager, for use inside of the TestRepoFactory.
    pub fn new_test(repo_name: String, content_manager: Box<dyn FileContentManager>) -> Self {
        Self {
            repo_name,
            hooks: HashMap::new(),
            bookmark_hooks: HashMap::new(),
            regex_hooks: Vec::new(),
            content_manager,
            reviewers_membership: NeverMember::new().into(),
            admin_membership: NeverMember::new().into(),
            scuba: MononokeScubaSampleBuilder::with_discard(),
            all_hooks_bypassed: false,
            scuba_bypassed_commits: MononokeScubaSampleBuilder::with_discard(),
        }
    }

    pub fn register_changeset_hook(
        &mut self,
        hook_name: &str,
        hook: Box<dyn ChangesetHook>,
        config: HookConfig,
    ) {
        self.hooks
            .insert(hook_name.to_string(), Hook::from_changeset(hook, config));
    }

    pub fn register_file_hook(
        &mut self,
        hook_name: &str,
        hook: Box<dyn FileHook>,
        config: HookConfig,
    ) {
        self.hooks
            .insert(hook_name.to_string(), Hook::from_file(hook, config));
    }

    pub fn set_hooks_for_bookmark(&mut self, bookmark: BookmarkOrRegex, hooks: Vec<String>) {
        match bookmark {
            BookmarkOrRegex::Bookmark(bookmark) => {
                self.bookmark_hooks.insert(bookmark, hooks);
            }
            BookmarkOrRegex::Regex(regex) => {
                self.regex_hooks.push((regex.into_inner(), hooks));
            }
        }
    }

    pub(crate) fn get_reviewers_perm_checker(&self) -> ArcMembershipChecker {
        self.reviewers_membership.clone()
    }

    pub fn get_admin_perm_checker(&self) -> ArcMembershipChecker {
        self.admin_membership.clone()
    }

    pub fn hooks_exist_for_bookmark(&self, bookmark: &BookmarkKey) -> bool {
        if self.bookmark_hooks.contains_key(bookmark) {
            return true;
        }

        let bookmark = bookmark.as_str();
        self.regex_hooks
            .iter()
            .any(|(regex, _)| regex.is_match(bookmark))
    }

    pub fn repo_name(&self) -> &String {
        &self.repo_name
    }

    fn hooks_for_bookmark<'a>(
        &'a self,
        bookmark: &BookmarkKey,
    ) -> impl Iterator<Item = &'a str> + Clone {
        let mut hooks: Vec<&'a str> = match self.bookmark_hooks.get(bookmark) {
            Some(hooks) => hooks.iter().map(|a| a.as_str()).collect(),
            None => Vec::new(),
        };

        let bookmark_str = bookmark.to_string();
        for (regex, r_hooks) in &self.regex_hooks {
            if regex.is_match(&bookmark_str) {
                hooks.extend(r_hooks.iter().map(|a| a.as_str()));
            }
        }

        hooks.into_iter()
    }

    pub fn all_hooks_bypassed(&self) -> bool {
        self.all_hooks_bypassed
    }

    pub fn scuba_bypassed_commits(&self) -> &MononokeScubaSampleBuilder {
        &self.scuba_bypassed_commits
    }

    pub async fn run_hooks_for_bookmark(
        &self,
        ctx: &CoreContext,
        changesets: impl Iterator<Item = &BonsaiChangeset> + Clone + itertools::Itertools,
        bookmark: &BookmarkKey,
        maybe_pushvars: Option<&HashMap<String, Bytes>>,
        cross_repo_push_source: CrossRepoPushSource,
        push_authored_by: PushAuthoredBy,
    ) -> Result<Vec<HookOutcome>, Error> {
        debug!(ctx.logger(), "Running hooks for bookmark {:?}", bookmark);

        let hooks = self.hooks_for_bookmark(bookmark);

        let futs = FuturesUnordered::new();

        let mut scuba = self.scuba.clone();
        let username = ctx.metadata().unix_name();
        let user_option = ctx.metadata().client_hostname().or(username);

        if let Some(user) = user_option {
            scuba.add("user", user);
        }

        for (cs, hook_name) in changesets.cartesian_product(hooks) {
            let hook = self
                .hooks
                .get(hook_name)
                .ok_or_else(|| ErrorKind::NoSuchHook(hook_name.to_string()))?;

            let mut scuba = scuba.clone();
            scuba.add("hook", hook_name.to_string());
            scuba.add("hash", cs.get_changeset_id().to_string());

            if let Some(bypass_reason) = get_bypass_reason(
                hook.get_config().bypass.as_ref(),
                cs.message(),
                maybe_pushvars,
            ) {
                scuba.add("bypass_reason", bypass_reason);
                scuba.log();
                continue;
            }

            for future in hook.get_futures(
                ctx,
                bookmark,
                &*self.content_manager,
                hook_name,
                cs,
                scuba,
                cross_repo_push_source,
                push_authored_by,
            ) {
                futs.push(future);
            }
        }
        futs.try_collect().await
    }
}

fn get_bypass_reason(
    bypass: Option<&HookBypass>,
    cs_msg: &str,
    maybe_pushvars: Option<&HashMap<String, Bytes>>,
) -> Option<String> {
    let bypass = bypass?;

    if let Some(bypass_string) = bypass.commit_message_bypass() {
        if cs_msg.contains(bypass_string) {
            return Some(format!("bypass string: {}", bypass_string));
        }
    }

    if let Some((name, value)) = bypass.pushvar_bypass() {
        if let Some(pushvars) = maybe_pushvars {
            let pushvar_val = pushvars
                .get(name)
                .map(|bytes| String::from_utf8(bytes.to_vec()));

            if let Some(Ok(pushvar_val)) = pushvar_val {
                if pushvar_val == *value {
                    return Some(format!("bypass pushvar: {}={}", name, value));
                }
            }
        }
    }

    None
}

/// An enum to represent if changesets were created by
/// a user or a service. If it is a service then most
/// hooks should just exit with a success because we trust
/// service writes. However, some hooks like verify_integrity
/// might still need to do some checks and/or logging.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum PushAuthoredBy {
    User,
    Service,
}

impl PushAuthoredBy {
    fn service(&self) -> bool {
        *self == PushAuthoredBy::Service
    }
}

/// An enum to represent the origin of the changeset
/// In the push-redirection scenario the changeset
/// is initially pushed to a small repo and then
/// redirected to a large one. An opposite of this
/// is a changeset, native to the large repo, which
/// does not go through the push-redirection.
/// We want hooks to be able to distinguish the two.
/// Note: this functionality is rarely needed. You
///       should always strive to write hooks that
///       ignore this information.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum CrossRepoPushSource {
    /// Cahngeset pushed directly to the large repo
    NativeToThisRepo,
    /// Changeset, push-redirected from the small repo
    PushRedirected,
}

enum Hook {
    Changeset(Box<dyn ChangesetHook>, HookConfig),
    File(Box<dyn FileHook>, HookConfig),
}

enum HookInstance<'a> {
    Changeset(&'a dyn ChangesetHook),
    File(&'a dyn FileHook, &'a MPath, Option<&'a BasicFileChange>),
}

impl<'a> HookInstance<'a> {
    async fn run(
        self,
        ctx: &CoreContext,
        bookmark: &BookmarkKey,
        content_manager: &dyn FileContentManager,
        hook_name: &str,
        mut scuba: MononokeScubaSampleBuilder,
        cs: &BonsaiChangeset,
        cs_id: ChangesetId,
        cross_repo_push_source: CrossRepoPushSource,
        push_authored_by: PushAuthoredBy,
    ) -> Result<HookOutcome, Error> {
        let (stats, result) = match self {
            Self::Changeset(hook) => {
                hook.run(
                    ctx,
                    bookmark,
                    cs,
                    content_manager,
                    cross_repo_push_source,
                    push_authored_by,
                )
                .map_ok(|exec| {
                    HookOutcome::ChangesetHook(
                        ChangesetHookExecutionID {
                            cs_id,
                            hook_name: hook_name.to_string(),
                        },
                        exec,
                    )
                })
                .timed()
                .await
            }
            Self::File(hook, path, change) => {
                hook.run(
                    ctx,
                    content_manager,
                    change,
                    path,
                    cross_repo_push_source,
                    push_authored_by,
                )
                .map_ok(|exec| {
                    HookOutcome::FileHook(
                        FileHookExecutionID {
                            cs_id,
                            path: path.clone(),
                            hook_name: hook_name.to_string(),
                        },
                        exec,
                    )
                })
                .timed()
                .await
            }
        };

        let mut errorcode = 0;
        let mut failed_hooks = 0;
        let mut stderr = None;

        match result.as_ref().map(HookOutcome::get_execution) {
            Ok(HookExecution::Accepted) => {
                // Nothing to do
            }
            Ok(HookExecution::Rejected(info)) => {
                failed_hooks = 1;
                stderr = Some(info.long_description.clone());
            }
            Err(e) => {
                errorcode = 1;
                stderr = Some(format!("{:?}", e));
            }
        };

        if let Some(stderr) = stderr {
            scuba.add("stderr", stderr);
        }

        let elapsed = stats.completion_time.as_millis() as i64;
        scuba
            .add("elapsed", elapsed)
            .add("total_time", elapsed)
            .add("errorcode", errorcode)
            .add("failed_hooks", failed_hooks)
            .log();

        result.map_err(|e| e.context(format!("while executing hook {}", hook_name)))
    }
}

impl Hook {
    pub fn from_changeset(hook: Box<dyn ChangesetHook>, config: HookConfig) -> Self {
        Self::Changeset(hook, config)
    }

    pub fn from_file(hook: Box<dyn FileHook>, config: HookConfig) -> Self {
        Self::File(hook, config)
    }

    pub fn get_config(&self) -> &HookConfig {
        match self {
            Self::Changeset(_, config) => config,
            Self::File(_, config) => config,
        }
    }

    pub fn get_futures<'a: 'cs, 'cs>(
        &'a self,
        ctx: &'a CoreContext,
        bookmark: &'a BookmarkKey,
        content_manager: &'a dyn FileContentManager,
        hook_name: &'cs str,
        cs: &'cs BonsaiChangeset,
        scuba: MononokeScubaSampleBuilder,
        cross_repo_push_source: CrossRepoPushSource,
        push_authored_by: PushAuthoredBy,
    ) -> impl Iterator<Item = impl Future<Output = Result<HookOutcome, Error>> + 'cs> + 'cs {
        let mut futures = Vec::new();

        let cs_id = cs.get_changeset_id();

        match self {
            Self::Changeset(hook, _) => futures.push(HookInstance::Changeset(&**hook).run(
                ctx,
                bookmark,
                content_manager,
                hook_name,
                scuba,
                cs,
                cs_id,
                cross_repo_push_source,
                push_authored_by,
            )),
            Self::File(hook, _) => {
                futures.extend(cs.simplified_file_changes().map(move |(path, change)| {
                    HookInstance::File(&**hook, path, change).run(
                        ctx,
                        bookmark,
                        content_manager,
                        hook_name,
                        scuba.clone(),
                        cs,
                        cs_id,
                        cross_repo_push_source,
                        push_authored_by,
                    )
                }))
            }
        };
        futures.into_iter()
    }
}

#[async_trait]
pub trait ChangesetHook: Send + Sync {
    async fn run<'this: 'cs, 'ctx: 'this, 'cs, 'fetcher: 'cs>(
        &'this self,
        ctx: &'ctx CoreContext,
        bookmark: &BookmarkKey,
        changeset: &'cs BonsaiChangeset,
        content_manager: &'fetcher dyn FileContentManager,
        cross_repo_push_source: CrossRepoPushSource,
        push_authored_by: PushAuthoredBy,
    ) -> Result<HookExecution, Error>;
}

#[async_trait]
pub trait FileHook: Send + Sync {
    async fn run<'this: 'change, 'ctx: 'this, 'change, 'fetcher: 'change, 'path: 'change>(
        &'this self,
        ctx: &'ctx CoreContext,
        content_manager: &'fetcher dyn FileContentManager,
        change: Option<&'change BasicFileChange>,
        path: &'path MPath,
        cross_repo_push_source: CrossRepoPushSource,
        push_authored_by: PushAuthoredBy,
    ) -> Result<HookExecution, Error>;
}

#[derive(Clone, Debug, PartialEq)]
pub enum HookOutcome {
    ChangesetHook(ChangesetHookExecutionID, HookExecution),
    FileHook(FileHookExecutionID, HookExecution),
}

impl fmt::Display for HookOutcome {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        match self {
            HookOutcome::ChangesetHook(id, exec) => {
                write!(f, "{} for {}: {}", id.hook_name, id.cs_id, exec)
            }
            HookOutcome::FileHook(id, exec) => write!(
                f,
                "{} for {} file {}: {}",
                id.hook_name, id.cs_id, id.path, exec
            ),
        }
    }
}

impl HookOutcome {
    pub fn is_rejection(&self) -> bool {
        match self.get_execution() {
            HookExecution::Accepted => false,
            HookExecution::Rejected(_) => true,
        }
    }

    pub fn is_accept(&self) -> bool {
        !self.is_rejection()
    }

    pub fn get_hook_name(&self) -> &str {
        match self {
            HookOutcome::ChangesetHook(id, _) => &id.hook_name,
            HookOutcome::FileHook(id, _) => &id.hook_name,
        }
    }

    pub fn get_file_path(&self) -> Option<&MPath> {
        match self {
            HookOutcome::ChangesetHook(..) => None,
            HookOutcome::FileHook(id, _) => Some(&id.path),
        }
    }

    pub fn get_changeset_id(&self) -> ChangesetId {
        match self {
            HookOutcome::ChangesetHook(id, _) => id.cs_id,
            HookOutcome::FileHook(id, _) => id.cs_id,
        }
    }

    pub fn get_execution(&self) -> &HookExecution {
        match self {
            HookOutcome::ChangesetHook(_, exec) => exec,
            HookOutcome::FileHook(_, exec) => exec,
        }
    }

    pub fn into_rejection(self) -> Option<HookRejection> {
        match self {
            HookOutcome::ChangesetHook(_, HookExecution::Accepted)
            | HookOutcome::FileHook(_, HookExecution::Accepted) => None,
            HookOutcome::ChangesetHook(
                ChangesetHookExecutionID { cs_id, hook_name },
                HookExecution::Rejected(reason),
            )
            | HookOutcome::FileHook(
                FileHookExecutionID {
                    cs_id,
                    hook_name,
                    path: _,
                },
                HookExecution::Rejected(reason),
            ) => Some(HookRejection {
                hook_name,
                cs_id,
                reason,
            }),
        }
    }
}

/// Instance of a hook rejecting a changeset.
#[derive(Clone, Debug, PartialEq)]
pub struct HookRejection {
    /// The hook that rejected the changeset.
    pub hook_name: String,

    /// The changeset that was rejected.
    pub cs_id: ChangesetId,

    /// Why the hook rejected the changeset.
    pub reason: HookRejectionInfo,
}

#[derive(Clone, Debug, PartialEq)]
pub enum HookExecution {
    Accepted,
    Rejected(HookRejectionInfo),
}

impl From<HookOutcome> for HookExecution {
    fn from(outcome: HookOutcome) -> Self {
        match outcome {
            HookOutcome::ChangesetHook(_, r) => r,
            HookOutcome::FileHook(_, r) => r,
        }
    }
}

impl fmt::Display for HookExecution {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        match self {
            HookExecution::Accepted => write!(f, "Accepted"),
            HookExecution::Rejected(reason) => write!(f, "Rejected: {}", reason.long_description),
        }
    }
}

/// Information on why the hook rejected the changeset
#[derive(Clone, Debug, PartialEq)]
pub struct HookRejectionInfo {
    /// A short description for summarizing this failure with similar failures
    pub description: Cow<'static, str>,
    /// A full explanation of what went wrong, suitable for presenting to the user (should include guidance for fixing this failure, where possible)
    pub long_description: String,
}

impl HookRejectionInfo {
    /// A rejection with just a short description
    /// The text should just summarize this failure - it should not be different on different invocations of this hook
    pub fn new(description: &'static str) -> Self {
        Self::new_long(description, description.to_string())
    }

    /// A rejection with a possible per-invocation fix explanation.
    pub fn new_long<OS>(description: &'static str, long_description: OS) -> Self
    where
        OS: Into<Option<String>>,
    {
        let long_description = long_description
            .into()
            .unwrap_or_else(|| description.to_string());
        Self {
            description: Cow::Borrowed(description),
            long_description,
        }
    }
}

#[derive(Clone, Debug, PartialEq, Hash, Eq)]
pub struct FileHookExecutionID {
    pub cs_id: ChangesetId,
    pub hook_name: String,
    pub path: MPath,
}

#[derive(Clone, Debug, PartialEq, Hash, Eq)]
pub struct ChangesetHookExecutionID {
    pub cs_id: ChangesetId,
    pub hook_name: String,
}

#[cfg(test)]
mod test {
    use super::*;

    #[test]
    fn test_commit_message_bypass() {
        let bypass = HookBypass::new_with_commit_msg("@mybypass".into());

        let r = get_bypass_reason(Some(&bypass), "@notbypass", None);
        assert!(r.is_none());

        let r = get_bypass_reason(Some(&bypass), "foo @mybypass bar", None);
        assert!(r.is_some());
    }

    #[test]
    fn test_pushvar_bypass() {
        let bypass = HookBypass::new_with_pushvar("myvar".into(), "myvalue".into());

        let mut m = HashMap::new();
        let r = get_bypass_reason(Some(&bypass), "", Some(&m));
        assert!(r.is_none()); // No var

        m.insert("somevar".into(), "somevalue".as_bytes().into());
        let r = get_bypass_reason(Some(&bypass), "", Some(&m));
        assert!(r.is_none()); // wrong var

        m.insert("myvar".into(), "somevalue".as_bytes().into());
        let r = get_bypass_reason(Some(&bypass), "", Some(&m));
        assert!(r.is_none()); // wrong value

        m.insert("myvar".into(), "myvalue foo".as_bytes().into());
        let r = get_bypass_reason(Some(&bypass), "", Some(&m));
        assert!(r.is_none()); // wrong value

        m.insert("myvar".into(), "myvalue".as_bytes().into());
        let r = get_bypass_reason(Some(&bypass), "", Some(&m));
        assert!(r.is_some());
    }
}


/*
 * Copyright (c) Meta Platforms, Inc. and affiliates.
 *
 * This software may be used and distributed according to the terms of the
 * GNU General Public License version 2.
 */

use anyhow::Error;
use async_trait::async_trait;
use bookmarks::BookmarkKey;
use context::CoreContext;
use mononoke_types::BonsaiChangeset;

use crate::ChangesetHook;
use crate::CrossRepoPushSource;
use crate::FileContentManager;
use crate::HookExecution;
use crate::HookRejectionInfo;
use crate::PushAuthoredBy;

#[derive(Clone, Debug)]
pub struct AlwaysFailChangeset;

impl AlwaysFailChangeset {
    pub fn new() -> Self {
        Self
    }
}

#[async_trait]
impl ChangesetHook for AlwaysFailChangeset {
    async fn run<'this: 'cs, 'ctx: 'this, 'cs, 'fetcher: 'cs>(
        &'this self,
        _ctx: &'ctx CoreContext,
        _bookmark: &BookmarkKey,
        _changeset: &'cs BonsaiChangeset,
        _content_manager: &'fetcher dyn FileContentManager,
        _cross_repo_push_source: CrossRepoPushSource,
        _push_authored_by: PushAuthoredBy,
    ) -> Result<HookExecution, Error> {
        Ok(HookExecution::Rejected(HookRejectionInfo::new(
            "This hook always fails",
        )))
    }
}


/*
 * Copyright (c) Meta Platforms, Inc. and affiliates.
 *
 * This software may be used and distributed according to the terms of the
 * GNU General Public License version 2.
 */

use anyhow::Error;
use async_trait::async_trait;
use bookmarks::BookmarkKey;
use context::CoreContext;
use mononoke_types::BonsaiChangeset;

use crate::ChangesetHook;
use crate::CrossRepoPushSource;
use crate::FileContentManager;
use crate::HookExecution;
use crate::HookRejectionInfo;
use crate::PushAuthoredBy;

#[derive(Clone, Debug)]
pub struct BlockEmptyCommit;

impl BlockEmptyCommit {
    pub fn new() -> Self {
        Self
    }
}

#[async_trait]
impl ChangesetHook for BlockEmptyCommit {
    async fn run<'this: 'cs, 'ctx: 'this, 'cs, 'fetcher: 'cs>(
        &'this self,
        _ctx: &'ctx CoreContext,
        _bookmark: &BookmarkKey,
        changeset: &'cs BonsaiChangeset,
        _content_manager: &'fetcher dyn FileContentManager,
        _cross_repo_push_source: CrossRepoPushSource,
        push_authored_by: PushAuthoredBy,
    ) -> Result<HookExecution, Error> {
        if push_authored_by.service() {
            return Ok(HookExecution::Accepted);
        }
        if changeset.file_changes_map().is_empty() {
            Ok(HookExecution::Rejected(HookRejectionInfo::new_long(
                "Empty commit is not allowed",
                "You must include file changes in your commit for it to land".to_string(),
            )))
        } else {
            Ok(HookExecution::Accepted)
        }
    }
}


/*
 * Copyright (c) Meta Platforms, Inc. and affiliates.
 *
 * This software may be used and distributed according to the terms of the
 * GNU General Public License version 2.
 */

use anyhow::Error;
use async_trait::async_trait;
use bookmarks::BookmarkKey;
use context::CoreContext;
use lazy_static::lazy_static;
use mononoke_types::BasicFileChange;
use mononoke_types::BonsaiChangeset;
use mononoke_types::MPath;
use regex::Regex;

use crate::ChangesetHook;
use crate::CrossRepoPushSource;
use crate::FileContentManager;
use crate::FileHook;
use crate::HookConfig;
use crate::HookExecution;
use crate::HookRejectionInfo;
use crate::PushAuthoredBy;

const NOCOMMIT_REGEX: &str =
    "(?i)(\x40(nocommit|no-commit|do-not-commit|do_not_commit))(\\W|_|\\z)";

#[derive(Clone, Debug)]
pub struct CheckNocommitHook;

impl CheckNocommitHook {
    pub fn new(_config: &HookConfig) -> Result<Self, Error> {
        Ok(Self)
    }
}

fn has_nocommit(text: &[u8]) -> Option<&str> {
    let Ok(text) = std::str::from_utf8(text) else {
        // Ignore binary files
        return None;
    };

    lazy_static! {
        static ref RE: Regex = Regex::new(NOCOMMIT_REGEX).unwrap();
    }

    RE.captures(text).map(|caps| caps.get(1).unwrap().as_str())
}

#[async_trait]
impl FileHook for CheckNocommitHook {
    async fn run<'this: 'change, 'ctx: 'this, 'change, 'fetcher: 'change, 'path: 'change>(
        &'this self,
        ctx: &'ctx CoreContext,
        content_manager: &'fetcher dyn FileContentManager,
        change: Option<&'change BasicFileChange>,
        path: &'path MPath,
        _cross_repo_push_source: CrossRepoPushSource,
        push_authored_by: PushAuthoredBy,
    ) -> Result<HookExecution, Error> {
        if push_authored_by.service() {
            return Ok(HookExecution::Accepted);
        }
        let maybe_text = match change {
            Some(change) => {
                content_manager
                    .get_file_text(ctx, change.content_id())
                    .await?
            }
            None => None,
        };

        Ok(match maybe_text {
            Some(text) => {
                if let Some(marker) = has_nocommit(text.as_ref()) {
                    let msg = format!("File contains a {} marker: {}", marker, path);
                    HookExecution::Rejected(HookRejectionInfo::new_long(
                        "File contains a nocommit marker",
                        msg,
                    ))
                } else {
                    HookExecution::Accepted
                }
            }
            None => HookExecution::Accepted,
        })
    }
}

#[async_trait]
impl ChangesetHook for CheckNocommitHook {
    async fn run<'this: 'cs, 'ctx: 'this, 'cs, 'fetcher: 'cs>(
        &'this self,
        _ctx: &'ctx CoreContext,
        _bookmark: &BookmarkKey,
        changeset: &'cs BonsaiChangeset,
        _content_manager: &'fetcher dyn FileContentManager,
        _cross_repo_push_source: CrossRepoPushSource,
        push_authored_by: PushAuthoredBy,
    ) -> Result<HookExecution, Error> {
        if push_authored_by.service() {
            return Ok(HookExecution::Accepted);
        }
        let message = changeset.message();

        let execution = if let Some(marker) = has_nocommit(message.as_bytes()) {
            HookExecution::Rejected(HookRejectionInfo::new_long(
                "Commit message contains a nocommit marker",
                format!("Commit message for contains a nocommit marker: {}", marker),
            ))
        } else {
            HookExecution::Accepted
        };

        Ok(execution)
    }
}

#[cfg(test)]
mod test {
    use super::*;

    #[test]
    fn test_find_nocommit() {
        assert_eq!(
            Some("\x40nocommit"),
            has_nocommit("\x40nocommit".as_bytes())
        );
        assert_eq!(Some("\x40nocommit"), has_nocommit(b"foo \x40nocommit"));
        assert_eq!(None, has_nocommit(b"foo nocommit"));
    }

    #[test]
    fn test_ignore_binary() {
        assert_eq!(None, has_nocommit(b"foo \x40nocommit \x80\x81"));
    }

    #[test]
    fn test_require_word_boundaries_after() {
        assert_eq!(None, has_nocommit(b"\x40nocommitfoo"));
        assert_eq!(Some("\x40nocommit"), has_nocommit(b"foo\x40nocommit"));
        assert_eq!(Some("\x40nocommit"), has_nocommit(b"foo_\x40nocommit\""));
    }

    #[test]
    fn test_matches_underscores_before_and_after() {
        assert_eq!(Some("\x40nocommit"), has_nocommit(b"__\x40nocommit"));
        assert_eq!(Some("\x40nocommit"), has_nocommit(b"\x40nocommit__"));
    }

    #[test]
    fn test_also_matches_no_commit() {
        assert_eq!(Some("\x40no-commit"), has_nocommit(b"foo \x40no-commit"));
    }

    #[test]
    fn test_also_matches_do_not_commit() {
        assert_eq!(
            Some("\x40do-not-commit"),
            has_nocommit(b"foo \x40do-not-commit")
        );
    }

    #[test]
    fn test_also_matches_do_not_commit_with_underscores() {
        assert_eq!(
            Some("\x40do_not_commit"),
            has_nocommit(b"foo \x40do_not_commit")
        );
    }

    #[test]
    fn test_also_matches_do_not_commit_capitalized() {
        assert_eq!(
            Some("\x40DO_NOT_COMMIT"),
            has_nocommit(b"foo \x40DO_NOT_COMMIT")
        );
    }
}


/*
 * Copyright (c) Meta Platforms, Inc. and affiliates.
 *
 * This software may be used and distributed according to the terms of the
 * GNU General Public License version 2.
 */

use std::collections::HashSet;

use anyhow::Error;
use async_trait::async_trait;
use context::CoreContext;
use maplit::hashset;
use mononoke_types::BasicFileChange;
use mononoke_types::MPath;

use crate::CrossRepoPushSource;
use crate::FileContentManager;
use crate::FileHook;
use crate::HookExecution;
use crate::HookRejectionInfo;
use crate::PushAuthoredBy;

pub struct ConflictMarkers {
    allowed_suffixes: HashSet<&'static [u8]>,
}

impl ConflictMarkers {
    pub fn new() -> Self {
        Self {
            allowed_suffixes: hashset! {b"rst" as &[u8], b"markdown" as &[u8], b"md" as &[u8], b"rdoc" as &[u8]},
        }
    }
}

#[async_trait]
impl FileHook for ConflictMarkers {
    async fn run<'this: 'change, 'ctx: 'this, 'change, 'fetcher: 'change, 'path: 'change>(
        &'this self,
        ctx: &'ctx CoreContext,
        content_manager: &'fetcher dyn FileContentManager,
        change: Option<&'change BasicFileChange>,
        path: &'path MPath,
        _cross_repo_push_source: CrossRepoPushSource,
        push_authored_by: PushAuthoredBy,
    ) -> Result<HookExecution, Error> {
        if push_authored_by.service() {
            return Ok(HookExecution::Accepted);
        }
        let change = match change {
            Some(change) => change,
            None => return Ok(HookExecution::Accepted),
        };

        let mut filename_iter = path.basename().as_ref().rsplit(|c| *c == b'.');
        let suffix = filename_iter.next().expect("File without a name");
        if filename_iter.next().is_some() && self.allowed_suffixes.contains(suffix) {
            return Ok(HookExecution::Accepted);
        }

        let text = content_manager
            .get_file_text(ctx, change.content_id())
            .await?;
        if let Some(text) = text {
            for line in text.as_ref().split(|c| *c == b'\r' || *c == b'\n') {
                if line.starts_with(b">>>>>>> ") || line.starts_with(b"<<<<<<< ") {
                    return Ok(HookExecution::Rejected(HookRejectionInfo::new_long(
                        "Conflict markers found",
                        format!("Conflict markers were found in file '{}'", path),
                    )));
                }
            }
        }
        Ok(HookExecution::Accepted)
    }
}


/*
 * Copyright (c) Meta Platforms, Inc. and affiliates.
 *
 * This software may be used and distributed according to the terms of the
 * GNU General Public License version 2.
 */

use anyhow::Context;
use anyhow::Result;
use async_trait::async_trait;
use context::CoreContext;
use metaconfig_types::HookConfig;
use mononoke_types::BasicFileChange;
use mononoke_types::MPath;

use super::LuaPattern;
use crate::CrossRepoPushSource;
use crate::FileContentManager;
use crate::FileHook;
use crate::HookExecution;
use crate::HookRejectionInfo;
use crate::PushAuthoredBy;

#[derive(Default)]
pub struct DenyFilesBuilder {
    /// Deny patterns for all sources of pushes
    all_push_sources_deny_patterns: Option<Vec<String>>,
    /// Deny patterns for pushes, originally intended to this repo
    /// (as opposed to push-redirected ones)
    native_push_only_deny_patterns: Option<Vec<String>>,
}

impl DenyFilesBuilder {
    pub fn set_from_config(mut self, config: &HookConfig) -> Self {
        if let Some(v) = config.string_lists.get("deny_patterns") {
            self = self.all_push_sources_deny_patterns(v)
        }
        if let Some(v) = config.string_lists.get("native_push_only_deny_patterns") {
            self = self.native_push_only_deny_patterns(v)
        }
        self
    }

    pub fn all_push_sources_deny_patterns(
        mut self,
        strs: impl IntoIterator<Item = impl AsRef<str>>,
    ) -> Self {
        self.all_push_sources_deny_patterns =
            Some(strs.into_iter().map(|s| String::from(s.as_ref())).collect());
        self
    }

    pub fn native_push_only_deny_patterns(
        mut self,
        strs: impl IntoIterator<Item = impl AsRef<str>>,
    ) -> Self {
        self.native_push_only_deny_patterns =
            Some(strs.into_iter().map(|s| String::from(s.as_ref())).collect());
        self
    }

    pub fn build(self) -> Result<DenyFiles> {
        Ok(DenyFiles {
            all_push_sources_deny_patterns: self
                .all_push_sources_deny_patterns
                .unwrap_or_default()
                .into_iter()
                .map(LuaPattern::try_from)
                .collect::<Result<Vec<_>, _>>()
                .context("Failed to create LuaPattern for all_push_sources_deny_patterns")?,
            native_push_only_deny_patterns: self
                .native_push_only_deny_patterns
                .unwrap_or_default()
                .into_iter()
                .map(LuaPattern::try_from)
                .collect::<Result<Vec<_>, _>>()
                .context("Failed to create LuaPattern for native_push_only_deny_patterns")?,
        })
    }
}

pub struct DenyFiles {
    /// Deny patterns for all sources of pushes
    all_push_sources_deny_patterns: Vec<LuaPattern>,
    /// Deny patterns for pushes, originally intended to this repo
    /// (as opposed to push-redirected ones)
    native_push_only_deny_patterns: Vec<LuaPattern>,
}

impl DenyFiles {
    pub fn builder() -> DenyFilesBuilder {
        DenyFilesBuilder::default()
    }
}

#[async_trait]
impl FileHook for DenyFiles {
    async fn run<'this: 'change, 'ctx: 'this, 'change, 'fetcher: 'change, 'path: 'change>(
        &'this self,
        _ctx: &'ctx CoreContext,
        _content_manager: &'fetcher dyn FileContentManager,
        change: Option<&'change BasicFileChange>,
        path: &'path MPath,
        cross_repo_push_source: CrossRepoPushSource,
        push_authored_by: PushAuthoredBy,
    ) -> Result<HookExecution> {
        if push_authored_by.service() {
            return Ok(HookExecution::Accepted);
        }

        deny_unacceptable_patterns(
            &self.all_push_sources_deny_patterns,
            &self.native_push_only_deny_patterns,
            path,
            cross_repo_push_source,
            change,
        )
    }
}

fn rejection<'a, 'b>(path: &'a String, pattern: &'b LuaPattern) -> HookExecution {
    HookExecution::Rejected(HookRejectionInfo::new_long(
        "Denied filename matched name pattern",
        format!(
            "Denied filename '{}' matched name pattern '{}'. Rename or remove this file and try again.",
            path, pattern
        ),
    ))
}

/// Easily-testable business logic of the `DenyFiles` hook
fn deny_unacceptable_patterns<'a, 'b, 'c>(
    all_patterns: &'a [LuaPattern],
    native_patterns: &'a [LuaPattern],
    path: &'b MPath,
    cross_repo_push_source: CrossRepoPushSource,
    change: Option<&'c BasicFileChange>,
) -> Result<HookExecution> {
    let path = path.to_string();
    if CrossRepoPushSource::NativeToThisRepo == cross_repo_push_source {
        for pattern in native_patterns {
            if pattern.is_match(&path) {
                return Ok(rejection(&path, pattern));
            }
        }
    }
    if change.is_none() {
        // It is acceptable to delete any file
        return Ok(HookExecution::Accepted);
    }

    for pattern in all_patterns {
        if pattern.is_match(&path) {
            return Ok(rejection(&path, pattern));
        }
    }

    Ok(HookExecution::Accepted)
}

#[cfg(test)]
mod test {
    use mononoke_types::FileType;
    use mononoke_types_mocks::contentid::TWOS_CTID;

    use super::*;

    fn setup_patterns() -> (Vec<LuaPattern>, Vec<LuaPattern>) {
        let all: Vec<LuaPattern> = vec!["all".try_into().unwrap()];
        let native: Vec<LuaPattern> = vec!["native".try_into().unwrap()];
        (all, native)
    }

    fn basic_change() -> BasicFileChange {
        BasicFileChange::new(TWOS_CTID, FileType::Regular, 10)
    }

    fn mpath(s: &str) -> MPath {
        MPath::new(s).unwrap()
    }

    #[test]
    fn test_denied_in_any_push() {
        let (all, native) = setup_patterns();
        let mp = mpath("all/1");
        let r = deny_unacceptable_patterns(
            &all,
            &native,
            &mp,
            CrossRepoPushSource::NativeToThisRepo,
            Some(&basic_change()),
        )
        .unwrap();
        assert!(matches!(r, HookExecution::Rejected(_)));

        let r = deny_unacceptable_patterns(
            &all,
            &native,
            &mp,
            CrossRepoPushSource::PushRedirected,
            Some(&basic_change()),
        )
        .unwrap();
        assert!(matches!(r, HookExecution::Rejected(_)));
    }

    #[test]
    fn test_denied_only_in_native_push() {
        let (all, native) = setup_patterns();
        let mp = mpath("native/1");
        let r = deny_unacceptable_patterns(
            &all,
            &native,
            &mp,
            CrossRepoPushSource::NativeToThisRepo,
            Some(&basic_change()),
        )
        .unwrap();
        assert!(matches!(r, HookExecution::Rejected(_)));

        let r = deny_unacceptable_patterns(
            &all,
            &native,
            &mp,
            CrossRepoPushSource::PushRedirected,
            Some(&basic_change()),
        )
        .unwrap();
        assert!(matches!(r, HookExecution::Accepted));
    }

    #[test]
    fn test_remove_denied_only_in_native_push() {
        let (all, native) = setup_patterns();
        let mp = mpath("native/1");
        let r = deny_unacceptable_patterns(
            &all,
            &native,
            &mp,
            CrossRepoPushSource::NativeToThisRepo,
            None,
        )
        .unwrap();
        assert!(matches!(r, HookExecution::Rejected(_)));

        let r = deny_unacceptable_patterns(
            &all,
            &native,
            &mp,
            CrossRepoPushSource::PushRedirected,
            Some(&basic_change()),
        )
        .unwrap();
        assert!(matches!(r, HookExecution::Accepted));
    }

    #[test]
    fn test_allowed_in_any_push() {
        let (all, native) = setup_patterns();
        let mp = mpath("ababagalamaga");
        let r = deny_unacceptable_patterns(
            &all,
            &native,
            &mp,
            CrossRepoPushSource::NativeToThisRepo,
            Some(&basic_change()),
        )
        .unwrap();
        assert!(matches!(r, HookExecution::Accepted));

        let r = deny_unacceptable_patterns(
            &all,
            &native,
            &mp,
            CrossRepoPushSource::PushRedirected,
            Some(&basic_change()),
        )
        .unwrap();
        assert!(matches!(r, HookExecution::Accepted));
    }
}