/*
 * 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 context::CoreContext;
use cross_repo_sync::CommitSyncer;
use cross_repo_sync::Repo as CrossRepo;
use futures_stats::FutureStats;
use mononoke_types::ChangesetId;
use scuba_ext::MononokeScubaSampleBuilder;
use slog::error;
use slog::info;
use slog::warn;
use slog::Logger;
use synced_commit_mapping::SyncedCommitMapping;

use crate::sync::SyncResult;

pub const SCUBA_TABLE: &str = "mononoke_x_repo_sync";

const SOURCE_REPO: &str = "source_repo";
const TARGET_REPO: &str = "target_repo";
const SOURCE_CS_ID: &str = "source_cs_id";
const SYNC_TYPE_ARG: &str = "sync_type";
const TARGET_CS_ID: &str = "target_cs_id";
const DURATION_MS: &str = "duration_ms";
const ERROR: &str = "error";
const SUCCESS: &str = "success";

/// Populate the `scuba_sample` with fields, common for
/// this tailer run
pub fn add_common_fields<M: SyncedCommitMapping + Clone + 'static, R: CrossRepo>(
    scuba_sample: &mut MononokeScubaSampleBuilder,
    commit_syncer: &CommitSyncer<M, R>,
) {
    scuba_sample
        .add(SOURCE_REPO, commit_syncer.get_source_repo_id().id())
        .add(TARGET_REPO, commit_syncer.get_target_repo_id().id());
}

/// Log the fact of successful syncing of the single changeset to Scuba
fn log_success_to_scuba(
    mut scuba_sample: MononokeScubaSampleBuilder,
    source_cs_id: ChangesetId,
    maybe_synced_cs_id: Option<ChangesetId>,
    stats: FutureStats,
) {
    scuba_sample
        .add(DURATION_MS, stats.completion_time.as_millis() as u64)
        .add(SUCCESS, 1)
        .add(SOURCE_CS_ID, format!("{}", source_cs_id));
    if let Some(cs_id) = maybe_synced_cs_id {
        // Not producing changeset in a target repo is possible,
        // when syncing just dropped all the changes in the commit
        scuba_sample.add(TARGET_CS_ID, format!("{}", cs_id));
    }
    scuba_sample.log();
}

/// Log the fact of failed syncing of the single changeset to Scuba
fn log_error_to_scuba(
    mut scuba_sample: MononokeScubaSampleBuilder,
    source_cs_id: ChangesetId,
    stats: FutureStats,
    error_string: String,
) {
    scuba_sample.add(SUCCESS, 0).add(ERROR, error_string);
    scuba_sample.add(DURATION_MS, stats.completion_time.as_millis() as u64);
    scuba_sample.add(SOURCE_CS_ID, format!("{}", source_cs_id));
    scuba_sample.log();
}

fn log_success_to_logger(
    logger: &Logger,
    source_cs_id: &ChangesetId,
    maybe_synced_cs_id: &Option<ChangesetId>,
    stats: &FutureStats,
) {
    let duration = stats.completion_time.as_millis();
    match maybe_synced_cs_id {
        Some(synced_cs_id) => {
            info!(
                logger,
                "changeset {} synced as {} in {}ms", source_cs_id, synced_cs_id, duration,
            );
        }
        None => {
            info!(
                logger,
                "Syncing {} succeeded in {}ms but did not produce a changeset in the taret repo.",
                source_cs_id,
                duration,
            );
        }
    };
}

fn log_error_to_logger(
    logger: &Logger,
    action: &'static str,
    source_cs_id: &ChangesetId,
    stats: &FutureStats,
    error_string: &String,
) {
    let duration = stats.completion_time.as_millis();
    error!(
        logger,
        "{} {} failed in {}ms: {}", action, source_cs_id, duration, error_string
    );
}

/// Log the fact of syncing of a single changeset both to Scuba and to slog
fn log_sync_single_changeset_result(
    ctx: CoreContext,
    scuba_sample: MononokeScubaSampleBuilder,
    bcs_id: ChangesetId,
    res: &Result<Option<ChangesetId>, Error>,
    stats: FutureStats,
) {
    match res {
        Ok(maybe_synced_cs_id) => {
            log_success_to_logger(ctx.logger(), &bcs_id, maybe_synced_cs_id, &stats);
            log_success_to_scuba(scuba_sample, bcs_id, *maybe_synced_cs_id, stats);
        }
        Err(e) => {
            let es = format!("{}", e);
            log_error_to_logger(ctx.logger(), "Syncing", &bcs_id, &stats, &es);
            log_error_to_scuba(scuba_sample, bcs_id, stats, es);
        }
    }
}

pub fn log_pushrebase_sync_single_changeset_result(
    ctx: CoreContext,
    mut scuba_sample: MononokeScubaSampleBuilder,
    bcs_id: ChangesetId,
    res: &Result<Option<ChangesetId>, Error>,
    stats: FutureStats,
) {
    scuba_sample.add(SYNC_TYPE_ARG, "pushrebase");
    log_sync_single_changeset_result(ctx, scuba_sample, bcs_id, res, stats)
}

pub fn log_non_pushrebase_sync_single_changeset_result(
    ctx: CoreContext,
    mut scuba_sample: MononokeScubaSampleBuilder,
    bcs_id: ChangesetId,
    res: &Result<Option<ChangesetId>, Error>,
    stats: FutureStats,
) {
    scuba_sample.add(SYNC_TYPE_ARG, "non-pushrebase");
    log_sync_single_changeset_result(ctx, scuba_sample, bcs_id, res, stats)
}

pub fn log_bookmark_deletion_result(
    mut scuba_sample: MononokeScubaSampleBuilder,
    res: &Result<(), Error>,
    stats: FutureStats,
) {
    scuba_sample.add(SYNC_TYPE_ARG, "bookmark_deletion");
    match res {
        Ok(()) => {
            scuba_sample.add(SUCCESS, 1);
        }
        Err(ref err) => {
            scuba_sample.add(SUCCESS, 0).add(ERROR, format!("{}", err));
        }
    }
    scuba_sample.add(DURATION_MS, stats.completion_time.as_millis() as u64);
    scuba_sample.log();
}

pub fn log_backpressure(
    ctx: &CoreContext,
    entries: u64,
    mut scuba_sample: MononokeScubaSampleBuilder,
) {
    let msg = format!("{} entries in backsyncer queue, waiting...", entries);

    info!(ctx.logger(), "{}", msg);
    scuba_sample.log_with_msg("Backpressure", Some(msg));
}

pub fn log_bookmark_update_result(
    ctx: &CoreContext,
    entry_id: i64,
    mut scuba_sample: MononokeScubaSampleBuilder,
    res: &Result<SyncResult, Error>,
    stats: FutureStats,
) {
    scuba_sample.add(DURATION_MS, stats.completion_time.as_millis() as u64);
    match res {
        Ok(SyncResult::Synced(_)) => {
            info!(
                ctx.logger(),
                "successful sync bookmark update log #{}", entry_id
            );
            scuba_sample.add(SUCCESS, 1);
            scuba_sample.log();
        }
        Ok(SyncResult::SkippedNoKnownVersion) => {
            warn!(
                ctx.logger(),
                "Skipped syncing log entry #{} because no mapping version found. Is it a new root commit in the repo?",
                entry_id
            );
            scuba_sample.add(SUCCESS, 1);
            scuba_sample.log();
        }
        Err(ref err) => {
            error!(
                ctx.logger(),
                "failed to sync bookmark update log #{}, {}", entry_id, err
            );
            scuba_sample.add(SUCCESS, 0);
            scuba_sample.add(ERROR, format!("{}", err));
            scuba_sample.log();
        }
    }
}

/// Log a Scuba sample, which will allow us to check that the tailer
/// is not dead, even when no commits are being synced
pub fn log_noop_iteration(mut scuba_sample: MononokeScubaSampleBuilder) {
    scuba_sample.add(SUCCESS, 1);
    scuba_sample.log();
}


/*
 * 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::time::Duration;

use anyhow::format_err;
use anyhow::Error;
use blobrepo::BlobRepo;
use clap_old::ArgMatches;
use cmdlib::args::MononokeMatches;
use cmdlib::helpers;
use context::CoreContext;
use mononoke_types::ChangesetId;
use scuba_ext::MononokeScubaSampleBuilder;

use crate::cli::ARG_COMMIT;
use crate::cli::ARG_LOG_TO_SCUBA;
use crate::cli::ARG_SLEEP_SECS;
use crate::reporting::SCUBA_TABLE;

const DEFAULT_SLEEP_SECS: u64 = 10;

pub async fn get_starting_commit<'a>(
    ctx: &CoreContext,
    matches: &ArgMatches<'a>,
    blobrepo: BlobRepo,
) -> Result<ChangesetId, Error> {
    let str_value = matches
        .value_of(ARG_COMMIT)
        .ok_or_else(|| format_err!("{} argument is required", ARG_COMMIT))
        .map(|s| s.to_owned())?;
    helpers::csid_resolve(ctx, &blobrepo, str_value).await
}

pub fn get_scuba_sample<'a>(
    ctx: CoreContext,
    matches: &MononokeMatches<'a>,
) -> MononokeScubaSampleBuilder {
    let log_to_scuba = matches.is_present(ARG_LOG_TO_SCUBA);
    let mut scuba_sample = if log_to_scuba {
        MononokeScubaSampleBuilder::new(ctx.fb, SCUBA_TABLE)
            .expect("Couldn't create scuba sample builder")
    } else {
        MononokeScubaSampleBuilder::with_discard()
    };
    scuba_sample.add_common_server_data();
    scuba_sample
}

pub fn get_sleep_duration<'a>(matches: &ArgMatches<'a>) -> Result<Duration, Error> {
    let secs = match matches.value_of(ARG_SLEEP_SECS) {
        Some(sleep_secs_str) => sleep_secs_str
            .parse::<u64>()
            .map_err(|_| format_err!("{} must be a valid u64", ARG_SLEEP_SECS)),
        None => Ok(DEFAULT_SLEEP_SECS),
    }?;
    Ok(Duration::from_secs(secs))
}


/*
 * 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::HashMap;
use std::collections::HashSet;

use anyhow::format_err;
use anyhow::Context;
use anyhow::Error;
use blobstore::Loadable;
use bookmarks::BookmarkKey;
use bookmarks::BookmarkUpdateLogEntry;
use bookmarks::BookmarkUpdateReason;
use bookmarks::BookmarksRef;
use changeset_fetcher::ChangesetFetcherArc;
use changeset_fetcher::ChangesetFetcherRef;
use commit_graph::CommitGraphRef;
use context::CoreContext;
use cross_repo_sync::find_toposorted_unsynced_ancestors;
use cross_repo_sync::CandidateSelectionHint;
use cross_repo_sync::CommitSyncContext;
use cross_repo_sync::CommitSyncOutcome;
use cross_repo_sync::CommitSyncer;
use cross_repo_sync::PushrebaseRewriteDates;
use futures::future::try_join_all;
use futures::stream::TryStreamExt;
use futures::try_join;
use futures_stats::TimedFutureExt;
use metaconfig_types::CommitSyncConfigVersion;
use mononoke_types::ChangesetId;
use repo_blobstore::RepoBlobstoreRef;
use repo_identity::RepoIdentityRef;
use scuba_ext::MononokeScubaSampleBuilder;
use slog::info;
use slog::warn;
use synced_commit_mapping::SyncedCommitMapping;

use crate::reporting::log_bookmark_deletion_result;
use crate::reporting::log_non_pushrebase_sync_single_changeset_result;
use crate::reporting::log_pushrebase_sync_single_changeset_result;
use crate::Repo;

#[derive(Debug, Eq, PartialEq)]
pub enum SyncResult {
    Synced(Vec<ChangesetId>),
    // SkippedNoKnownVersion usually happens when a new root commit was
    // added to the repository, and its descendant are not merged into any
    // mainline bookmark. See top level doc comments in main file for
    // more details.
    SkippedNoKnownVersion,
}

/// Sync all new commits and update the bookmark that were introduced by BookmarkUpdateLogEntry
/// in the source repo.
/// This function:
/// 1) Finds commits that needs syncing
/// 2) Syncs them from source repo into target (*)
/// 3) Updates the bookmark
///
/// (*) There are two ways how a commit can be synced from source repo into a target repo.
/// It can either be rewritten and saved into a target repo, or rewritten and pushrebased
/// in a target repo. This depends on which bookmark introduced a commit - if it's a
/// common_pushrebase_bookmark (usually "master"), then a commit will be pushrebased.
/// Otherwise it will be synced without pushrebase.
pub async fn sync_single_bookmark_update_log<M: SyncedCommitMapping + Clone + 'static, R: Repo>(
    ctx: &CoreContext,
    commit_syncer: &CommitSyncer<M, R>,
    entry: BookmarkUpdateLogEntry,
    common_pushrebase_bookmarks: &HashSet<BookmarkKey>,
    mut scuba_sample: MononokeScubaSampleBuilder,
    pushrebase_rewrite_dates: PushrebaseRewriteDates,
) -> Result<SyncResult, Error> {
    info!(ctx.logger(), "processing log entry #{}", entry.id);
    let bookmark = commit_syncer.get_bookmark_renamer().await?(&entry.bookmark_name)
        .ok_or_else(|| format_err!("unexpected empty bookmark rename"))?;
    scuba_sample
        .add("source_bookmark_name", format!("{}", entry.bookmark_name))
        .add("target_bookmark_name", format!("{}", bookmark));

    let to_cs_id = match entry.to_changeset_id {
        Some(to_cs_id) => to_cs_id,
        None => {
            // This is a bookmark deletion - just delete a bookmark and exit,
            // no need to sync commits
            process_bookmark_deletion(
                ctx,
                commit_syncer,
                scuba_sample,
                &bookmark,
                common_pushrebase_bookmarks,
            )
            .await?;

            return Ok(SyncResult::Synced(vec![]));
        }
    };

    sync_commit_and_ancestors(
        ctx,
        commit_syncer,
        entry.from_changeset_id,
        to_cs_id,
        Some(bookmark),
        common_pushrebase_bookmarks,
        scuba_sample,
        pushrebase_rewrite_dates,
    )
    .await

    // TODO(stash): test with other movers
    // Note: counter update might fail after a successful sync
}

pub async fn sync_commit_and_ancestors<M: SyncedCommitMapping + Clone + 'static, R: Repo>(
    ctx: &CoreContext,
    commit_syncer: &CommitSyncer<M, R>,
    from_cs_id: Option<ChangesetId>,
    to_cs_id: ChangesetId,
    maybe_bookmark: Option<BookmarkKey>,
    common_pushrebase_bookmarks: &HashSet<BookmarkKey>,
    scuba_sample: MononokeScubaSampleBuilder,
    pushrebase_rewrite_dates: PushrebaseRewriteDates,
) -> Result<SyncResult, Error> {
    let (unsynced_ancestors, unsynced_ancestors_versions) =
        find_toposorted_unsynced_ancestors(ctx, commit_syncer, to_cs_id.clone()).await?;

    let version = if !unsynced_ancestors_versions.has_ancestor_with_a_known_outcome() {
        return Ok(SyncResult::SkippedNoKnownVersion);
    } else {
        let maybe_version = unsynced_ancestors_versions
            .get_only_version()
            .with_context(|| format!("failed to backsync cs id {}", to_cs_id))?;
        maybe_version.ok_or_else(|| {
            format_err!(
                "failed to sync {} - all of the ancestors are NotSyncCandidate",
                to_cs_id
            )
        })?
    };

    let len = unsynced_ancestors.len();
    info!(ctx.logger(), "{} unsynced ancestors of {}", len, to_cs_id);

    if let Some(bookmark) = &maybe_bookmark {
        if common_pushrebase_bookmarks.contains(bookmark) {
            // This is a commit that was introduced by common pushrebase bookmark (e.g. "master").
            // Use pushrebase to sync a commit.
            if let Some(from_cs_id) = from_cs_id {
                check_forward_move(ctx, commit_syncer, to_cs_id, from_cs_id).await?;
            }

            return sync_commits_via_pushrebase(
                ctx,
                commit_syncer,
                bookmark,
                common_pushrebase_bookmarks,
                scuba_sample.clone(),
                unsynced_ancestors,
                &version,
                pushrebase_rewrite_dates,
            )
            .await
            .map(SyncResult::Synced);
        }
    }

    // Use a normal sync since a bookmark is not a common pushrebase bookmark
    let mut res = vec![];
    for cs_id in unsynced_ancestors {
        let synced = sync_commit_without_pushrebase(
            ctx,
            commit_syncer,
            scuba_sample.clone(),
            cs_id,
            common_pushrebase_bookmarks,
            &version,
        )
        .await?;
        res.extend(synced);
    }
    let maybe_remapped_cs_id = find_remapped_cs_id(ctx, commit_syncer, to_cs_id).await?;
    let remapped_cs_id =
        maybe_remapped_cs_id.ok_or_else(|| format_err!("unknown sync outcome for {}", to_cs_id))?;
    if let Some(bookmark) = maybe_bookmark {
        move_or_create_bookmark(
            ctx,
            commit_syncer.get_target_repo(),
            &bookmark,
            remapped_cs_id,
        )
        .await?;
    }
    Ok(SyncResult::Synced(res))
}

/// This function syncs commits via pushrebase with a caveat - some commits shouldn't be
/// pushrebased! Consider pushing of a merge
///
/// ```text
///  source repo (X - synced commit, O - unsynced commit)
///
///     O <- merge commit (this commit needs to be pushrebased in target repo)
///    / |
///   X   O <- this commit DOES NOT NEED to be pushrebased in the target repo
///  ...  |
///      ...
///
/// Just as normal pushrebase behaves while pushing merges, we rebase the actual merge
/// commit and it's ancestors, but we don't rebase merge ancestors.
/// ```
pub async fn sync_commits_via_pushrebase<M: SyncedCommitMapping + Clone + 'static, R: Repo>(
    ctx: &CoreContext,
    commit_syncer: &CommitSyncer<M, R>,
    bookmark: &BookmarkKey,
    common_pushrebase_bookmarks: &HashSet<BookmarkKey>,
    scuba_sample: MononokeScubaSampleBuilder,
    unsynced_ancestors: Vec<ChangesetId>,
    version: &CommitSyncConfigVersion,
    pushrebase_rewrite_dates: PushrebaseRewriteDates,
) -> Result<Vec<ChangesetId>, Error> {
    let source_repo = commit_syncer.get_source_repo();
    // It stores commits that were introduced as part of current bookmark update, but that
    // shouldn't be pushrebased.
    let mut no_pushrebase = HashSet::new();
    let mut res = vec![];

    // Iterate in reverse order i.e. descendants before ancestors
    for cs_id in unsynced_ancestors.iter().rev() {
        if no_pushrebase.contains(cs_id) {
            continue;
        }

        let bcs = cs_id.load(ctx, source_repo.repo_blobstore()).await?;

        let mut parents = bcs.parents();
        let maybe_p1 = parents.next();
        let maybe_p2 = parents.next();
        if let (Some(p1), Some(p2)) = (maybe_p1, maybe_p2) {
            if parents.next().is_some() {
                return Err(format_err!("only 2 parent merges are supported"));
            }

            no_pushrebase.extend(validate_if_new_repo_merge(ctx, source_repo, p1, p2).await?);
        }
    }

    for cs_id in unsynced_ancestors {
        let maybe_new_cs_id = if no_pushrebase.contains(&cs_id) {
            sync_commit_without_pushrebase(
                ctx,
                commit_syncer,
                scuba_sample.clone(),
                cs_id,
                common_pushrebase_bookmarks,
                version,
            )
            .await?
        } else {
            info!(
                ctx.logger(),
                "syncing {} via pushrebase for {}", cs_id, bookmark
            );
            let (stats, result) = pushrebase_commit(
                ctx,
                commit_syncer,
                bookmark,
                cs_id,
                pushrebase_rewrite_dates,
            )
            .timed()
            .await;
            log_pushrebase_sync_single_changeset_result(
                ctx.clone(),
                scuba_sample.clone(),
                cs_id,
                &result,
                stats,
            );
            let maybe_new_cs_id = result?;
            maybe_new_cs_id.into_iter().collect()
        };

        res.extend(maybe_new_cs_id);
    }
    Ok(res)
}

pub async fn sync_commit_without_pushrebase<M: SyncedCommitMapping + Clone + 'static, R: Repo>(
    ctx: &CoreContext,
    commit_syncer: &CommitSyncer<M, R>,
    scuba_sample: MononokeScubaSampleBuilder,
    cs_id: ChangesetId,
    common_pushrebase_bookmarks: &HashSet<BookmarkKey>,
    version: &CommitSyncConfigVersion,
) -> Result<Vec<ChangesetId>, Error> {
    info!(ctx.logger(), "syncing {}", cs_id);
    let bcs = cs_id
        .load(ctx, commit_syncer.get_source_repo().repo_blobstore())
        .await?;

    let (stats, result) = if bcs.is_merge() {
        // We allow syncing of a merge only if there's no intersection between ancestors of this
        // merge commit and ancestors of common pushrebase bookmark in target repo.
        // The code below does exactly that - it fetches common_pushrebase_bookmarks and parent
        // commits from the target repo, and then it checks if there are no intersection.
        let target_repo = commit_syncer.get_target_repo();
        let mut book_values = vec![];
        for common_bookmark in common_pushrebase_bookmarks {
            book_values.push(target_repo.bookmarks().get(ctx.clone(), common_bookmark));
        }

        let book_values = try_join_all(book_values).await?;
        let book_values = book_values.into_iter().flatten().collect();

        let parents = try_join_all(
            bcs.parents()
                .map(|p| find_remapped_cs_id(ctx, commit_syncer, p)),
        )
        .await?;
        let maybe_independent_branch = check_if_independent_branch_and_return(
            ctx,
            target_repo,
            parents.into_iter().flatten().collect(),
            book_values,
        )
        .await?;

        // Merge is from a branch completely independent from common_pushrebase_bookmark -
        // it's fine to sync it.
        if maybe_independent_branch.is_some() {
            commit_syncer
                .unsafe_always_rewrite_sync_commit(
                    ctx,
                    cs_id,
                    None,
                    version,
                    CommitSyncContext::XRepoSyncJob,
                )
                .timed()
                .await
        } else {
            return Err(format_err!(
                "cannot sync merge commit - one of it's ancestors is an ancestor of a common pushrebase bookmark"
            ));
        }
    } else {
        commit_syncer
            .unsafe_sync_commit_with_expected_version(
                ctx,
                cs_id,
                CandidateSelectionHint::Only,
                version.clone(),
                CommitSyncContext::XRepoSyncJob,
            )
            .timed()
            .await
    };

    log_non_pushrebase_sync_single_changeset_result(
        ctx.clone(),
        scuba_sample.clone(),
        cs_id,
        &result,
        stats,
    );

    let maybe_cs_id = result?;
    Ok(maybe_cs_id.into_iter().collect())
}

async fn process_bookmark_deletion<M: SyncedCommitMapping + Clone + 'static, R: Repo>(
    ctx: &CoreContext,
    commit_syncer: &CommitSyncer<M, R>,
    scuba_sample: MononokeScubaSampleBuilder,
    bookmark: &BookmarkKey,
    common_pushrebase_bookmarks: &HashSet<BookmarkKey>,
) -> Result<(), Error> {
    if common_pushrebase_bookmarks.contains(bookmark) {
        Err(format_err!(
            "unexpected deletion of a shared bookmark {}",
            bookmark
        ))
    } else {
        info!(ctx.logger(), "deleting bookmark {}", bookmark);
        let (stats, result) =
            delete_bookmark(ctx.clone(), commit_syncer.get_target_repo(), bookmark)
                .timed()
                .await;
        log_bookmark_deletion_result(scuba_sample, &result, stats);
        result
    }
}

async fn check_forward_move<M: SyncedCommitMapping + Clone + 'static, R: Repo>(
    ctx: &CoreContext,
    commit_syncer: &CommitSyncer<M, R>,
    to_cs_id: ChangesetId,
    from_cs_id: ChangesetId,
) -> Result<(), Error> {
    if !commit_syncer
        .get_source_repo()
        .commit_graph()
        .is_ancestor(ctx, from_cs_id, to_cs_id)
        .await?
    {
        return Err(format_err!(
            "non-forward moves of shared bookmarks are not allowed"
        ));
    }
    Ok(())
}

async fn find_remapped_cs_id<M: SyncedCommitMapping + Clone + 'static, R: Repo>(
    ctx: &CoreContext,
    commit_syncer: &CommitSyncer<M, R>,
    orig_cs_id: ChangesetId,
) -> Result<Option<ChangesetId>, Error> {
    let maybe_sync_outcome = commit_syncer
        .get_commit_sync_outcome(ctx, orig_cs_id)
        .await?;
    use CommitSyncOutcome::*;
    match maybe_sync_outcome {
        Some(RewrittenAs(cs_id, _)) | Some(EquivalentWorkingCopyAncestor(cs_id, _)) => {
            Ok(Some(cs_id))
        }
        Some(NotSyncCandidate(_)) => Err(format_err!("unexpected NotSyncCandidate")),
        None => Ok(None),
    }
}

async fn pushrebase_commit<M: SyncedCommitMapping + Clone + 'static, R: Repo>(
    ctx: &CoreContext,
    commit_syncer: &CommitSyncer<M, R>,
    bookmark: &BookmarkKey,
    cs_id: ChangesetId,
    pushrebase_rewrite_dates: PushrebaseRewriteDates,
) -> Result<Option<ChangesetId>, Error> {
    let source_repo = commit_syncer.get_source_repo();
    let bcs = cs_id.load(ctx, source_repo.repo_blobstore()).await?;
    commit_syncer
        .unsafe_sync_commit_pushrebase(
            ctx,
            bcs,
            bookmark.clone(),
            CommitSyncContext::XRepoSyncJob,
            pushrebase_rewrite_dates,
        )
        .await
}

/// Function validates if a this merge is supported for x-repo sync. At the moment we support
/// only a single type of merges - merge that introduces a new repo i.e. merge p1 and p2
/// have no shared history.
///
///     O <- merge commit to sync
///    / |
///   O   O <- these are new commits we need to sync
///   |   |
///   |   ...
///
/// This function returns new commits that were introduced by this merge
async fn validate_if_new_repo_merge(
    ctx: &CoreContext,
    repo: &(
         impl ChangesetFetcherRef
         + ChangesetFetcherArc
         + RepoBlobstoreRef
         + RepoIdentityRef
         + CommitGraphRef
     ),
    p1: ChangesetId,
    p2: ChangesetId,
) -> Result<Vec<ChangesetId>, Error> {
    let p1gen = repo.changeset_fetcher().get_generation_number(ctx, p1);
    let p2gen = repo.changeset_fetcher().get_generation_number(ctx, p2);
    let (p1gen, p2gen) = try_join!(p1gen, p2gen)?;
    // FIXME: this code has an assumption that parent with a smaller generation number is a
    // parent that introduces a new repo. This is usually the case, however it might not be true
    // in some rare cases.
    let (larger_gen, smaller_gen) = if p1gen > p2gen { (p1, p2) } else { (p2, p1) };

    let err_msg = || format_err!("unsupported merge - only merges of new repos are supported");

    // Check if this is a diamond merge i.e. check if any of the ancestor of smaller_gen
    // is also ancestor of larger_gen.
    let maybe_independent_branch =
        check_if_independent_branch_and_return(ctx, repo, vec![smaller_gen], vec![larger_gen])
            .await?;

    let independent_branch = maybe_independent_branch.ok_or_else(err_msg)?;

    Ok(independent_branch)
}

/// Checks if `branch_tips` and their ancestors have no intersection with ancestors of
/// other_branches. If there are no intersection then branch_tip and it's ancestors are returned,
/// i.e. (::branch_tips) is returned in mercurial's revset terms
async fn check_if_independent_branch_and_return(
    ctx: &CoreContext,
    repo: &(impl ChangesetFetcherArc + RepoBlobstoreRef + RepoIdentityRef + CommitGraphRef),
    branch_tips: Vec<ChangesetId>,
    other_branches: Vec<ChangesetId>,
) -> Result<Option<Vec<ChangesetId>>, Error> {
    let blobstore = repo.repo_blobstore();
    let bcss = repo
        .commit_graph()
        .ancestors_difference_stream(ctx, branch_tips.clone(), other_branches)
        .await?
        .map_ok({ move |cs| async move { Ok(cs.load(ctx, blobstore).await?) } })
        .try_buffered(100)
        .try_collect::<Vec<_>>()
        .await?;

    let bcss: Vec<_> = bcss.into_iter().rev().collect();
    let mut cs_to_parents: HashMap<_, Vec<_>> = HashMap::new();
    for bcs in &bcss {
        let cs_id = bcs.get_changeset_id();
        cs_to_parents.insert(cs_id, bcs.parents().collect());
    }

    // If any of branch_tips hasn't been returned, then it was an ancestor of some of the
    // other_branches.
    for tip in branch_tips {
        if !cs_to_parents.contains_key(&tip) {
            return Ok(None);
        }
    }

    for parents in cs_to_parents.values() {
        for p in parents {
            if !cs_to_parents.contains_key(p) {
                return Ok(None);
            }
        }
    }

    Ok(Some(cs_to_parents.keys().cloned().collect()))
}

async fn delete_bookmark(
    ctx: CoreContext,
    repo: &impl BookmarksRef,
    bookmark: &BookmarkKey,
) -> Result<(), Error> {
    let mut book_txn = repo.bookmarks().create_transaction(ctx.clone());
    let maybe_bookmark_val = repo.bookmarks().get(ctx.clone(), bookmark).await?;
    if let Some(bookmark_value) = maybe_bookmark_val {
        book_txn.delete(bookmark, bookmark_value, BookmarkUpdateReason::XRepoSync)?;
        let res = book_txn.commit().await?;

        if res {
            Ok(())
        } else {
            Err(format_err!("failed to delete a bookmark"))
        }
    } else {
        warn!(
            ctx.logger(),
            "Not deleting '{}' bookmark because it does not exist", bookmark
        );
        Ok(())
    }
}

async fn move_or_create_bookmark(
    ctx: &CoreContext,
    repo: &impl BookmarksRef,
    bookmark: &BookmarkKey,
    cs_id: ChangesetId,
) -> Result<(), Error> {
    let maybe_bookmark_val = repo.bookmarks().get(ctx.clone(), bookmark).await?;

    let mut book_txn = repo.bookmarks().create_transaction(ctx.clone());
    match maybe_bookmark_val {
        Some(old_bookmark_val) => {
            book_txn.update(
                bookmark,
                cs_id,
                old_bookmark_val,
                BookmarkUpdateReason::XRepoSync,
            )?;
        }
        None => {
            book_txn.create(bookmark, cs_id, BookmarkUpdateReason::XRepoSync)?;
        }
    }
    let res = book_txn.commit().await?;

    if res {
        Ok(())
    } else {
        Err(format_err!("failed to move or create a bookmark"))
    }
}

#[cfg(test)]
mod test {
    use bookmarks::BookmarkUpdateLogRef;
    use bookmarks::BookmarksMaybeStaleExt;
    use bookmarks::Freshness;
    use cross_repo_sync::validation;
    use cross_repo_sync_test_utils::init_small_large_repo;
    use cross_repo_sync_test_utils::TestRepo;
    use fbinit::FacebookInit;
    use futures::TryStreamExt;
    use maplit::hashset;
    use mutable_counters::MutableCountersRef;
    use synced_commit_mapping::SqlSyncedCommitMapping;
    use tests_utils::bookmark;
    use tests_utils::resolve_cs_id;
    use tests_utils::CreateCommitContext;
    use tokio::runtime::Runtime;

    use super::*;

    #[fbinit::test]
    fn test_simple(fb: FacebookInit) -> Result<(), Error> {
        let runtime = Runtime::new()?;
        runtime.block_on(async move {
            let ctx = CoreContext::test_mock(fb);
            let (syncers, _, _, _) = init_small_large_repo(&ctx).await?;
            let commit_syncer = syncers.small_to_large;
            let smallrepo = commit_syncer.get_source_repo();

            // Single commit
            let new_master = CreateCommitContext::new(&ctx, &smallrepo, vec!["master"])
                .add_file("newfile", "newcontent")
                .commit()
                .await?;
            bookmark(&ctx, &smallrepo, "master")
                .set_to(new_master)
                .await?;

            sync_and_validate(&ctx, &commit_syncer).await?;

            let non_master_commit = CreateCommitContext::new(&ctx, &smallrepo, vec!["master"])
                .add_file("nonmasterfile", "nonmastercontent")
                .commit()
                .await?;
            bookmark(&ctx, &smallrepo, "nonmasterbookmark")
                .set_to(non_master_commit)
                .await?;

            sync_and_validate(&ctx, &commit_syncer).await?;

            // Create a stack of commits
            let first_in_stack = CreateCommitContext::new(&ctx, &smallrepo, vec!["master"])
                .add_file("stack", "first")
                .commit()
                .await?;

            let second_in_stack = CreateCommitContext::new(&ctx, &smallrepo, vec![first_in_stack])
                .add_file("stack", "second")
                .commit()
                .await?;
            bookmark(&ctx, &smallrepo, "master")
                .set_to(second_in_stack)
                .await?;

            // Create a commit that's based on commit rewritten with noop mapping
            // - it should NOT be rewritten
            let premove = CreateCommitContext::new(&ctx, &smallrepo, vec!["premove"])
                .add_file("premove", "premovecontent")
                .commit()
                .await?;
            bookmark(&ctx, &smallrepo, "newpremove")
                .set_to(premove)
                .await?;

            // Move a bookmark
            bookmark(&ctx, &smallrepo, "newpremove")
                .set_to("premove")
                .await?;
            sync_and_validate(&ctx, &commit_syncer).await?;
            let commit_sync_outcome = commit_syncer
                .get_commit_sync_outcome(&ctx, premove)
                .await?
                .ok_or_else(|| format_err!("commit sync outcome not set"))?;
            match commit_sync_outcome {
                CommitSyncOutcome::RewrittenAs(cs_id, version) => {
                    assert_eq!(version, CommitSyncConfigVersion("noop".to_string()));
                    assert_eq!(cs_id, premove);
                }
                _ => {
                    return Err(format_err!("unexpected outcome"));
                }
            };

            // Delete bookmarks
            bookmark(&ctx, &smallrepo, "newpremove").delete().await?;
            bookmark(&ctx, &smallrepo, "nonmasterbookmark")
                .delete()
                .await?;

            sync_and_validate(&ctx, &commit_syncer).await?;
            Ok(())
        })
    }

    #[fbinit::test]
    fn test_simple_merge(fb: FacebookInit) -> Result<(), Error> {
        let runtime = Runtime::new()?;
        runtime.block_on(async move {
            let ctx = CoreContext::test_mock(fb);
            let (syncers, _, _, _) = init_small_large_repo(&ctx).await?;
            let commit_syncer = syncers.small_to_large;
            let smallrepo = commit_syncer.get_source_repo();

            // Merge new repo
            let first_new_repo = CreateCommitContext::new_root(&ctx, &smallrepo)
                .add_file("firstnewrepo", "newcontent")
                .commit()
                .await?;
            let second_new_repo = CreateCommitContext::new(&ctx, &smallrepo, vec![first_new_repo])
                .add_file("secondnewrepo", "anothercontent")
                .commit()
                .await?;

            bookmark(&ctx, &smallrepo, "newrepohead")
                .set_to(second_new_repo)
                .await?;

            let res = sync(
                &ctx,
                &commit_syncer,
                &hashset! {BookmarkKey::new("master")?},
                PushrebaseRewriteDates::No,
            )
            .await?;
            assert_eq!(res.last(), Some(&SyncResult::SkippedNoKnownVersion));

            let merge = CreateCommitContext::new(&ctx, &smallrepo, vec!["master", "newrepohead"])
                .commit()
                .await?;

            bookmark(&ctx, &smallrepo, "master").set_to(merge).await?;

            sync_and_validate_with_common_bookmarks(
                &ctx,
                &commit_syncer,
                &hashset! {BookmarkKey::new("master")?},
                &hashset! {BookmarkKey::new("newrepohead")?},
                PushrebaseRewriteDates::No,
            )
            .await?;

            // Diamond merges are not allowed
            let diamond_merge =
                CreateCommitContext::new(&ctx, &smallrepo, vec!["master", "newrepohead"])
                    .commit()
                    .await?;
            bookmark(&ctx, &smallrepo, "master")
                .set_to(diamond_merge)
                .await?;
            assert!(sync_and_validate(&ctx, &commit_syncer,).await.is_err());
            Ok(())
        })
    }

    #[fbinit::test]
    fn test_merge_added_in_single_bookmark_update(fb: FacebookInit) -> Result<(), Error> {
        let runtime = Runtime::new()?;
        runtime.block_on(async move {
            let ctx = CoreContext::test_mock(fb);
            let (syncers, _, _, _) = init_small_large_repo(&ctx).await?;
            let commit_syncer = syncers.small_to_large;
            let smallrepo = commit_syncer.get_source_repo();

            // Merge new repo
            let first_new_repo = CreateCommitContext::new_root(&ctx, &smallrepo)
                .add_file("firstnewrepo", "newcontent")
                .commit()
                .await?;
            let second_new_repo = CreateCommitContext::new(&ctx, &smallrepo, vec![first_new_repo])
                .add_file("secondnewrepo", "anothercontent")
                .commit()
                .await?;

            let master_cs_id = resolve_cs_id(&ctx, &smallrepo, "master").await?;
            let merge =
                CreateCommitContext::new(&ctx, &smallrepo, vec![master_cs_id, second_new_repo])
                    .commit()
                    .await?;

            bookmark(&ctx, &smallrepo, "master").set_to(merge).await?;
            sync_and_validate(&ctx, &commit_syncer).await?;

            Ok(())
        })
    }

    #[fbinit::test]
    fn test_merge_of_a_merge_one_step(fb: FacebookInit) -> Result<(), Error> {
        let runtime = Runtime::new()?;
        runtime.block_on(async move {
            let ctx = CoreContext::test_mock(fb);
            let (syncers, _, _, _) = init_small_large_repo(&ctx).await?;
            let commit_syncer = syncers.small_to_large;
            let smallrepo = commit_syncer.get_source_repo();

            // Merge new repo, which itself has a merge
            let first_new_repo = CreateCommitContext::new_root(&ctx, &smallrepo)
                .add_file("firstnewrepo", "newcontent")
                .commit()
                .await?;
            let second_new_repo = CreateCommitContext::new_root(&ctx, &smallrepo)
                .add_file("secondnewrepo", "anothercontent")
                .commit()
                .await?;

            let merge_new_repo =
                CreateCommitContext::new(&ctx, &smallrepo, vec![first_new_repo, second_new_repo])
                    .commit()
                    .await?;

            let master_cs_id = resolve_cs_id(&ctx, &smallrepo, "master").await?;
            let merge =
                CreateCommitContext::new(&ctx, &smallrepo, vec![master_cs_id, merge_new_repo])
                    .commit()
                    .await?;

            bookmark(&ctx, &smallrepo, "master").set_to(merge).await?;
            sync_and_validate(&ctx, &commit_syncer).await?;

            Ok(())
        })
    }

    #[fbinit::test]
    fn test_merge_of_a_merge_two_steps(fb: FacebookInit) -> Result<(), Error> {
        let runtime = Runtime::new()?;
        runtime.block_on(async move {
            let ctx = CoreContext::test_mock(fb);
            let (syncers, _, _, _) = init_small_large_repo(&ctx).await?;
            let commit_syncer = syncers.small_to_large;
            let smallrepo = commit_syncer.get_source_repo();

            // Merge new repo, which itself has a merge
            let first_new_repo = CreateCommitContext::new_root(&ctx, &smallrepo)
                .add_file("firstnewrepo", "newcontent")
                .commit()
                .await?;
            let second_new_repo = CreateCommitContext::new_root(&ctx, &smallrepo)
                .add_file("secondnewrepo", "anothercontent")
                .commit()
                .await?;

            let merge_new_repo =
                CreateCommitContext::new(&ctx, &smallrepo, vec![first_new_repo, second_new_repo])
                    .commit()
                    .await?;
            bookmark(&ctx, &smallrepo, "newrepoimport")
                .set_to(merge_new_repo)
                .await?;
            let res = sync(
                &ctx,
                &commit_syncer,
                &hashset! {BookmarkKey::new("master")?},
                PushrebaseRewriteDates::No,
            )
            .await?;
            assert_eq!(res.last(), Some(&SyncResult::SkippedNoKnownVersion));

            let merge = CreateCommitContext::new(&ctx, &smallrepo, vec!["master", "newrepoimport"])
                .commit()
                .await?;

            bookmark(&ctx, &smallrepo, "master").set_to(merge).await?;
            sync_and_validate_with_common_bookmarks(
                &ctx,
                &commit_syncer,
                &hashset! {BookmarkKey::new("master")?},
                &hashset! {BookmarkKey::new("newrepoimport")?},
                PushrebaseRewriteDates::No,
            )
            .await?;

            Ok(())
        })
    }

    #[fbinit::test]
    fn test_merge_non_shared_bookmark(fb: FacebookInit) -> Result<(), Error> {
        let runtime = Runtime::new()?;
        runtime.block_on(async move {
            let ctx = CoreContext::test_mock(fb);

            let (syncers, _, _, _) = init_small_large_repo(&ctx).await?;
            let commit_syncer = syncers.small_to_large;
            let smallrepo = commit_syncer.get_source_repo();

            let new_repo = CreateCommitContext::new_root(&ctx, &smallrepo)
                .add_file("firstnewrepo", "newcontent")
                .commit()
                .await?;
            bookmark(&ctx, &smallrepo, "newrepohead")
                .set_to(new_repo)
                .await?;
            let res = sync(
                &ctx,
                &commit_syncer,
                &hashset! {BookmarkKey::new("master")?},
                PushrebaseRewriteDates::No,
            )
            .await?;
            assert_eq!(res.last(), Some(&SyncResult::SkippedNoKnownVersion));

            let merge = CreateCommitContext::new(&ctx, &smallrepo, vec!["master", "newrepohead"])
                .commit()
                .await?;

            bookmark(&ctx, &smallrepo, "somebook").set_to(merge).await?;
            assert!(
                sync_and_validate_with_common_bookmarks(
                    &ctx,
                    &commit_syncer,
                    &hashset! {BookmarkKey::new("master")?},
                    &hashset! {BookmarkKey::new("newrepohead")?, BookmarkKey::new("somebook")?},
                    PushrebaseRewriteDates::No,
                )
                .await
                .is_err()
            );
            Ok(())
        })
    }

    #[fbinit::test]
    async fn test_merge_different_versions(fb: FacebookInit) -> Result<(), Error> {
        let ctx = CoreContext::test_mock(fb);
        let (syncers, _, _, _) = init_small_large_repo(&ctx).await?;
        let commit_syncer = syncers.small_to_large;
        let smallrepo = commit_syncer.get_source_repo();

        // Merge new repo
        let new_repo = CreateCommitContext::new_root(&ctx, &smallrepo)
            .add_file("firstnewrepo", "newcontent")
            .commit()
            .await?;

        bookmark(&ctx, &smallrepo, "another_pushrebase_bookmark")
            .set_to("premove")
            .await?;
        sync_and_validate_with_common_bookmarks(
            &ctx,
            &commit_syncer,
            &hashset! { BookmarkKey::new("master")?},
            &hashset! {},
            PushrebaseRewriteDates::No,
        )
        .await?;

        let merge = CreateCommitContext::new_root(&ctx, &smallrepo)
            .add_parent("premove")
            .add_parent(new_repo)
            .commit()
            .await?;
        bookmark(&ctx, &smallrepo, "another_pushrebase_bookmark")
            .set_to(merge)
            .await?;

        sync_and_validate_with_common_bookmarks(
            &ctx, &commit_syncer,
            &hashset!{ BookmarkKey::new("master")?, BookmarkKey::new("another_pushrebase_bookmark")?},
            &hashset!{},
                PushrebaseRewriteDates::No,
        ).await?;

        Ok(())
    }

    async fn sync_and_validate(
        ctx: &CoreContext,
        commit_syncer: &CommitSyncer<SqlSyncedCommitMapping, TestRepo>,
    ) -> Result<(), Error> {
        sync_and_validate_with_common_bookmarks(
            ctx,
            commit_syncer,
            &hashset! {BookmarkKey::new("master")?},
            &hashset! {},
            PushrebaseRewriteDates::No,
        )
        .await
    }

    async fn sync_and_validate_with_common_bookmarks(
        ctx: &CoreContext,
        commit_syncer: &CommitSyncer<SqlSyncedCommitMapping, TestRepo>,
        common_pushrebase_bookmarks: &HashSet<BookmarkKey>,
        should_be_missing: &HashSet<BookmarkKey>,
        pushrebase_rewrite_dates: PushrebaseRewriteDates,
    ) -> Result<(), Error> {
        let smallrepo = commit_syncer.get_source_repo();
        sync(
            ctx,
            commit_syncer,
            common_pushrebase_bookmarks,
            pushrebase_rewrite_dates,
        )
        .await?;

        let actually_missing = validation::find_bookmark_diff(ctx.clone(), commit_syncer)
            .await?
            .into_iter()
            .map(|diff| diff.target_bookmark().clone())
            .collect::<HashSet<_>>();
        println!("actually missing bookmarks: {:?}", actually_missing);
        assert_eq!(&actually_missing, should_be_missing,);

        let heads: Vec<_> = smallrepo
            .bookmarks()
            .get_heads_maybe_stale(ctx.clone())
            .try_collect()
            .await?;
        for head in heads {
            println!("verifying working copy for {}", head);
            validation::verify_working_copy(ctx.clone(), commit_syncer.clone(), head).await?;
        }

        Ok(())
    }

    async fn sync(
        ctx: &CoreContext,
        commit_syncer: &CommitSyncer<SqlSyncedCommitMapping, TestRepo>,
        common_pushrebase_bookmarks: &HashSet<BookmarkKey>,
        pushrebase_rewrite_dates: PushrebaseRewriteDates,
    ) -> Result<Vec<SyncResult>, Error> {
        let smallrepo = commit_syncer.get_source_repo();
        let megarepo = commit_syncer.get_target_repo();

        let counter = crate::format_counter(commit_syncer);
        let start_from = megarepo
            .mutable_counters()
            .get_counter(ctx, &counter)
            .await?
            .unwrap_or(1);

        println!("start from: {}", start_from);
        let read_all = 65536;
        let log_entries: Vec<_> = smallrepo
            .bookmark_update_log()
            .read_next_bookmark_log_entries(
                ctx.clone(),
                start_from as u64,
                read_all,
                Freshness::MostRecent,
            )
            .try_collect()
            .await?;

        println!(
            "syncing log entries {:?}  from repo#{} to repo#{}",
            log_entries,
            smallrepo.repo_identity().id(),
            megarepo.repo_identity().id()
        );

        let mut res = vec![];
        for entry in log_entries {
            let entry_id = entry.id;
            let single_res = sync_single_bookmark_update_log(
                ctx,
                commit_syncer,
                entry,
                common_pushrebase_bookmarks,
                MononokeScubaSampleBuilder::with_discard(),
                pushrebase_rewrite_dates,
            )
            .await?;
            res.push(single_res);

            megarepo
                .mutable_counters()
                .set_counter(ctx, &counter, entry_id, None)
                .await?;
        }

        Ok(res)
    }
}


/*
 * 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::HashMap;
use std::collections::HashSet;
use std::sync::Arc;

use anyhow::Context;
use anyhow::Error;
use anyhow::Result;
use mercurial_types::MPath;
use mercurial_types::MPathElement;
use metaconfig_types::CommitSyncConfig;
use metaconfig_types::CommitSyncDirection;
use metaconfig_types::DefaultSmallToLargeCommitSyncPathAction;
use metaconfig_types::SmallRepoCommitSyncConfig;
use mononoke_types::RepositoryId;
use thiserror::Error;

#[derive(Debug, Error)]
pub enum ErrorKind {
    #[error("Cannot remove prefix, equal to the whole path")]
    RemovePrefixWholePathFailure,
    #[error("Cannot apply prefix action {0:?} to {1:?}")]
    PrefixActionFailure(PrefixAction, MPath),
    #[error("Small repo {0} not found")]
    SmallRepoNotFound(RepositoryId),
    #[error("Provided map is not prefix-free (e.g. {0:?} and {1:?})")]
    NonPrefixFreeMap(MPath, MPath),
}

/// A function to modify paths during repo sync
/// Here are the meanings of the return values:
/// - `Ok(Some(newpath))` - the path should be
///   replaced with `newpath` during sync
/// - `Ok(None)` - the path shoould not be synced
/// - `Err(e)` - the sync should fail, as this function
///   could not figure out how to rewrite path
pub type Mover = Arc<dyn Fn(&MPath) -> Result<Option<MPath>> + Send + Sync + 'static>;

/// A struct to contain forward and reverse `Mover`
pub struct Movers {
    pub mover: Mover,
    pub reverse_mover: Mover,
}

/// An action, configured for a given prefix
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PrefixAction {
    // The new path should have this prefix replaced with a new value
    Change(MPath),
    // The new path should have this prefix dropped
    RemovePrefix,
    // The path that matches this prefix should not be synced
    DoNotSync,
}

/// An action, applied to the entire path
#[derive(Debug, Clone, PartialEq, Eq)]
enum PathAction {
    // Change the path when syncing
    Change(MPath),
    // Do not sync this path
    DoNotSync,
}

/// Default action to apply to a path when syncing between two repos
#[derive(Debug, Clone)]
pub enum DefaultAction {
    /// Prepend path with this prefix
    PrependPrefix(MPath),
    /// Keep the path as is
    Preserve,
    /// Do not sync this path
    DoNotSync,
}

impl DefaultAction {
    /// Create `DefaultAction` for small-to-large sync
    fn from_default_small_repo_action(dsra: DefaultSmallToLargeCommitSyncPathAction) -> Self {
        match dsra {
            DefaultSmallToLargeCommitSyncPathAction::Preserve => DefaultAction::Preserve,
            DefaultSmallToLargeCommitSyncPathAction::PrependPrefix(mpath) => {
                DefaultAction::PrependPrefix(mpath)
            }
        }
    }
}

fn get_suffix_after<'a, 'b>(
    source_path: &'a MPath,
    candidate_prefix: &'b MPath,
) -> Option<Vec<&'a MPathElement>> {
    if !candidate_prefix.is_prefix_of(source_path) {
        None
    } else {
        Some(
            source_path
                .into_iter()
                .skip(candidate_prefix.num_components())
                .collect(),
        )
    }
}

/// Given the remainder of the path after a matching prefix
/// and a prefix action, produce a path action
fn get_path_action<'a, I: IntoIterator<Item = &'a MPathElement>>(
    source_path_minus_prefix: I,
    prefix_action: &PrefixAction,
) -> Result<PathAction> {
    match prefix_action {
        PrefixAction::DoNotSync => Ok(PathAction::DoNotSync),
        PrefixAction::RemovePrefix => {
            let elements: Vec<_> = source_path_minus_prefix.into_iter().cloned().collect();
            MPath::try_from(elements)
                .map(PathAction::Change)
                .map_err(|_| {
                    // This case means that we are trying to sync a file
                    // and are also asked to drop the entire path of this
                    // file.
                    // Note that `PrefixAction::RemovePrefix` can only be
                    // created in this module, and is only ever created
                    // as a reversal of `PrependPrefix` default action,
                    // when configuring sync from large to small repos.
                    // Therefore, this case can only be hit if the large
                    // repo contains a file, named exactly like the
                    // prefix in a `DefaultAction::PrependPrefix` and
                    // this is a mistake (either configuration or somebody
                    // checked in a file named like this).
                    // TODO(ikostia, T53963059): large repo should prohibit such files
                    Error::from(ErrorKind::RemovePrefixWholePathFailure)
                })
        }
        PrefixAction::Change(replacement_prefix) => Ok(PathAction::Change(
            replacement_prefix.join(source_path_minus_prefix),
        )),
    }
}

/// Create a `Mover`, given a path prefix map and a default action
pub fn mover_factory(
    prefix_map: HashMap<MPath, PrefixAction>,
    default_action: DefaultAction,
) -> Result<Mover> {
    // We want `prefix_map` to be ordered longest-to-shortest
    // to allow non-prefix-free maps in the future. For these kinds
    // of maps, we need to ensure we always try to match the longest
    // prefix first, as it's more specific.
    let prefix_map: Vec<(MPath, PrefixAction)> = {
        let mut v: Vec<(MPath, PrefixAction)> = prefix_map.into_iter().collect();
        v.sort_unstable_by_key(|(ref mpath, _)| mpath.len());
        v.reverse();
        v
    };

    Ok(Arc::new(move |source_path: &MPath| {
        let path_and_prefix_action = prefix_map
            .iter()
            .filter_map(|(candidate_prefix, candidate_action)| {
                get_suffix_after(source_path, candidate_prefix)
                    .map(move |suffix_after| (suffix_after, candidate_action))
            })
            .map(|(suffix_after, candidate_action)| {
                (
                    get_path_action(suffix_after, candidate_action),
                    candidate_action,
                )
            })
            .next();
        match path_and_prefix_action {
            None => Ok(match default_action.clone() {
                DefaultAction::PrependPrefix(prefix) => Some(prefix.join(source_path.into_iter())),
                DefaultAction::Preserve => Some(source_path.clone()),
                DefaultAction::DoNotSync => None,
            }),
            Some((result_path_action, orig_prefix_action)) => result_path_action
                .map(|path_action| match path_action {
                    PathAction::Change(path) => Some(path),
                    PathAction::DoNotSync => None,
                })
                .with_context(|| {
                    ErrorKind::PrefixActionFailure(orig_prefix_action.clone(), source_path.clone())
                })
                .map_err(Error::from),
        }
    }))
}

// Given a full sync config and a small repo id,
// split it into this repo the rest
fn get_small_repo_and_others_from_config(
    commit_sync_config: &CommitSyncConfig,
    small_repo_id: RepositoryId,
) -> Result<(&SmallRepoCommitSyncConfig, Vec<&SmallRepoCommitSyncConfig>)> {
    let small_repo = match &commit_sync_config.small_repos.get(&small_repo_id) {
        Some(config) => *config,
        None => return Err(Error::from(ErrorKind::SmallRepoNotFound(small_repo_id))),
    };
    let others: Vec<_> = commit_sync_config
        .small_repos
        .iter()
        .filter_map(|(k, v)| if k != &small_repo_id { Some(v) } else { None })
        .collect();
    Ok((small_repo, others))
}

/// Get a mover for small-to-large repo sync
pub fn get_small_to_large_mover(
    commit_sync_config: &CommitSyncConfig,
    small_repo_id: RepositoryId,
) -> Result<Mover> {
    let (source_repo_config, _) =
        get_small_repo_and_others_from_config(commit_sync_config, small_repo_id)?;
    let default_action = source_repo_config.default_action.clone();
    let prefix_map = source_repo_config.map.clone();

    let default_action = DefaultAction::from_default_small_repo_action(default_action);
    let prefix_map: HashMap<_, _> = prefix_map
        .into_iter()
        .map(|(k, v)| (k, PrefixAction::Change(v)))
        .collect();

    mover_factory(prefix_map, default_action)
}

/// Get a mover for a large-to-small repo sync
pub fn get_large_to_small_mover(
    commit_sync_config: &CommitSyncConfig,
    small_repo_id: RepositoryId,
) -> Result<Mover> {
    let (target_repo_config, other_repo_configs) =
        get_small_repo_and_others_from_config(commit_sync_config, small_repo_id)?;

    let target_repo_right_sides: HashSet<_> = target_repo_config.map.values().collect();

    let other_repo_right_sides: Vec<&MPath> = other_repo_configs
        .iter()
        .flat_map(|small_repo_config| {
            small_repo_config
                .map
                .values()
                .filter(|v| !target_repo_right_sides.contains(v))
        })
        .collect();

    let other_repo_prepended_prefixes: Vec<&MPath> = other_repo_configs
        .iter()
        .filter_map(
            |small_repo_config| match &small_repo_config.default_action {
                DefaultSmallToLargeCommitSyncPathAction::PrependPrefix(mp) => Some(mp),
                _ => None,
            },
        )
        .collect();

    // We reverse the direction of all path-to-path mappings
    let mut prefix_map: HashMap<MPath, PrefixAction> = target_repo_config
        .map
        .iter()
        .map(|(k, v)| (v.clone(), PrefixAction::Change(k.clone())))
        .collect();

    // Any path that synced exclusively from some other small repo,
    // needs to be not synced back to this small repo. "Exclusively" is
    // needed here, because it is possible that two small repos sync some
    // dir to the same location in a large repo (that dir is identical),
    // and in that case commits to this dir in a large repo need to
    // sync to *both* small repos.
    other_repo_right_sides
        .into_iter()
        .chain(other_repo_prepended_prefixes.into_iter())
        .for_each(|v| {
            prefix_map.insert(v.clone(), PrefixAction::DoNotSync);
        });

    // If small-to-large default action was not `Preserve`, we should
    // not sync this path, as `PrependPrefix` needs to be represented
    // by an individual `RemovePrefix` action in the map
    let default_action = match &target_repo_config.default_action {
        DefaultSmallToLargeCommitSyncPathAction::Preserve => DefaultAction::Preserve,
        DefaultSmallToLargeCommitSyncPathAction::PrependPrefix(prefix) => {
            prefix_map.insert(prefix.clone(), PrefixAction::RemovePrefix);
            DefaultAction::DoNotSync
        }
    };

    // default_large_to_small_mover is a mover that's built from the prefix_map and
    // default_action we've just constructed. However it doesn't work correctly for all edge
    // cases.
    //
    // In particular, there might be multiple large repo paths that remap to the same
    // small repo path.
    // Consider this config
    // default_action: DefaultSmallToLargeCommitSyncPathAction::PrependPrefix(mp("shifted")),
    // map: hashmap! {
    //     mp("preserved") => mp("preserved"),
    //     mp("preserved/excluded") => mp("shifted/preserved/excluded"),
    // },
    //
    // Now if we try to use default_large_to_small_mover to remap path shifted/preserved/1.txt,
    // then it will be remapped to preserved/1.txt, but this is incorrect, since preserved/1.txt
    // from small repo maps to preserved/1.txt in large repo.
    // To fix this issue we do the following: we take corresponding small_to_large mover
    // and remap back the path we got from applying default_large_to_small_mover.
    // If this path is equal to the original path then we consider that default_large_to_small_mover
    // returns correct path, otherwise we return None (i.e. a path from large repo doesn't remap
    // to a path from small repo).
    let default_large_to_small_mover = mover_factory(prefix_map, default_action)?;

    let small_to_large_mover = get_small_to_large_mover(commit_sync_config, small_repo_id)?;
    Ok(Arc::new(move |path: &MPath| -> Result<Option<MPath>> {
        let moved_large_to_small = default_large_to_small_mover(path)?;
        match moved_large_to_small {
            Some(moved_large_to_small) => {
                if small_to_large_mover(&moved_large_to_small)?.as_ref() == Some(path) {
                    Ok(Some(moved_large_to_small))
                } else {
                    Ok(None)
                }
            }
            None => Ok(None),
        }
    }))
}

/// Get a forward and a reverse `Mover`, stored in the `Movers` struct
pub fn get_movers(
    commit_sync_config: &CommitSyncConfig,
    small_repo_id: RepositoryId,
    direction: CommitSyncDirection,
) -> Result<Movers> {
    match direction {
        CommitSyncDirection::LargeToSmall => Ok(Movers {
            mover: get_large_to_small_mover(commit_sync_config, small_repo_id)?,
            reverse_mover: get_small_to_large_mover(commit_sync_config, small_repo_id)?,
        }),
        CommitSyncDirection::SmallToLarge => Ok(Movers {
            mover: get_small_to_large_mover(commit_sync_config, small_repo_id)?,
            reverse_mover: get_large_to_small_mover(commit_sync_config, small_repo_id)?,
        }),
    }
}

#[cfg(test)]
mod test {
    use maplit::hashmap;
    use metaconfig_types::CommitSyncConfigVersion;

    use super::*;

    fn mp(s: &'static str) -> MPath {
        MPath::new(s).unwrap()
    }

    fn mpe(s: &'static [u8]) -> MPathElement {
        MPathElement::new(s.to_vec()).unwrap()
    }

    #[test]
    fn test_get_suffix_after() {
        let foobar = mp("foo/bar");
        let foo = mp("foo");
        let bar = mp("bar");
        assert_eq!(get_suffix_after(&foobar, &bar), None);
        let r: Vec<&MPathElement> = get_suffix_after(&foobar, &foo).unwrap();
        assert_eq!(r, vec![&mpe(b"bar")]);
        let r: Vec<&MPathElement> = get_suffix_after(&foobar, &foobar).unwrap();
        assert!(r.is_empty());
    }

    #[test]
    fn test_get_path_action() {
        let foo_el = vec![mpe(b"foo")];
        assert_eq!(
            get_path_action(foo_el.iter(), &PrefixAction::DoNotSync).unwrap(),
            PathAction::DoNotSync
        );
        assert_eq!(
            get_path_action(foo_el.iter(), &PrefixAction::RemovePrefix).unwrap(),
            PathAction::Change(mp("foo"))
        );
        assert_eq!(
            get_path_action(foo_el.iter(), &PrefixAction::Change(mp("bar"))).unwrap(),
            PathAction::Change(mp("bar/foo"))
        );
    }

    #[test]
    fn test_non_prefix_free_mover() {
        let hm = hashmap! {
            mp("path/") => PrefixAction::Change(mp("shortest/renamed")),
            mp("path/which/is/longest") => PrefixAction::Change(mp("longest/renamed")),
            mp("path/which/") => PrefixAction::Change(mp("middle/renamed")),
        };
        let mover = mover_factory(hm, DefaultAction::DoNotSync).unwrap();
        assert_eq!(
            mover(&mp("path/which/is/longest/1.txt")).unwrap(),
            Some(mp("longest/renamed/1.txt"))
        );
        assert_eq!(
            mover(&mp("path/1.txt")).unwrap(),
            Some(mp("shortest/renamed/1.txt"))
        );
        assert_eq!(
            mover(&mp("path/which/2.txt")).unwrap(),
            Some(mp("middle/renamed/2.txt"))
        );
        assert_eq!(
            mover(&mp("path/which/subdir/2.txt")).unwrap(),
            Some(mp("middle/renamed/subdir/2.txt"))
        );
        assert_eq!(
            mover(&mp("path/subdir/1.txt")).unwrap(),
            Some(mp("shortest/renamed/subdir/1.txt"))
        );
    }

    #[test]
    fn test_mover() {
        let hm = hashmap! {
            mp("renameme") => PrefixAction::Change(mp("renamed")),
            mp("deleteme") => PrefixAction::DoNotSync,
            mp("shiftme") => PrefixAction::Change(mp("shifted/shiftme")),
            mp("removeme") => PrefixAction::RemovePrefix,
        };
        let mover = mover_factory(hm.clone(), DefaultAction::DoNotSync).unwrap();
        assert_eq!(mover(&mp("renameme/wow")).unwrap(), Some(mp("renamed/wow")));
        assert_eq!(mover(&mp("deleteme/wow")).unwrap(), None);
        assert_eq!(
            mover(&mp("shiftme/wow")).unwrap(),
            Some(mp("shifted/shiftme/wow"))
        );
        assert_eq!(mover(&mp("wow")).unwrap(), None);
        assert_eq!(mover(&mp("removeme/wow")).unwrap(), Some(mp("wow")));
        assert!(mover(&mp("removeme")).is_err());

        let mover = mover_factory(hm.clone(), DefaultAction::Preserve).unwrap();
        assert_eq!(mover(&mp("wow")).unwrap(), Some(mp("wow")));

        let mover = mover_factory(
            hm,
            DefaultAction::PrependPrefix(MPath::new("dude").unwrap()),
        )
        .unwrap();
        assert_eq!(mover(&mp("wow")).unwrap(), Some(mp("dude/wow")));
    }

    /*
    Below, the following sync config is tested:
    Small repo 1:
        (unmatched paths stay as they are)
        default action: preserve
        (a single dir is preserved from repo2, so has to be shifted in repo 1)
        "preserved2" => "repo1-rest/preserved2"
    Small repo 2:
        (unmatched paths go into "shifted2" subdir of a large repo)
        default action: prepend prefix "shifted2"
        (a single dir is preserved from repo2)
        "preserved2" => "preserved2"
        (some paths are moved into a different location)
        "sub1" => "repo2-rest/sub1"
        "sub2" => "repo2-rest/sub2"
    Note that in this configuration, the small repos have non-overlapping
    images in the big repo.
    */

    fn get_small_repo_sync_config_1_non_ovelapping() -> SmallRepoCommitSyncConfig {
        SmallRepoCommitSyncConfig {
            default_action: DefaultSmallToLargeCommitSyncPathAction::Preserve,
            map: hashmap! {
                mp("preserved2") => mp("repo1-rest/preserved2"),
            },
        }
    }

    fn get_small_repo_sync_config_2_non_ovelapping() -> SmallRepoCommitSyncConfig {
        SmallRepoCommitSyncConfig {
            default_action: DefaultSmallToLargeCommitSyncPathAction::PrependPrefix(mp("shifted2")),
            map: hashmap! {
                mp("preserved2") => mp("preserved2"),
                mp("sub1") => mp("repo2-rest/sub1"),
                mp("sub2") => mp("repo2-rest/sub2"),
            },
        }
    }

    fn get_large_repo_sync_config_non_overlapping() -> CommitSyncConfig {
        CommitSyncConfig {
            large_repo_id: RepositoryId::new(3),
            common_pushrebase_bookmarks: vec![],
            small_repos: hashmap! {
                RepositoryId::new(1) => get_small_repo_sync_config_1_non_ovelapping(),
                RepositoryId::new(2) => get_small_repo_sync_config_2_non_ovelapping(),
            },
            version_name: CommitSyncConfigVersion("TEST_VERSION_NAME".to_string()),
        }
    }

    #[test]
    fn test_get_small_to_large_mover_1_non_overlapping() {
        let large_sync_config = get_large_repo_sync_config_non_overlapping();
        let mover = get_small_to_large_mover(&large_sync_config, RepositoryId::new(1)).unwrap();

        // `preserved2` is a directory, preserved from repo2, so changes to
        // it in repo1 it have tbe shifted
        let f = mp("preserved2/f");
        assert_eq!(mover(&f).unwrap(), Some(mp("repo1-rest/preserved2/f")));
        let f = mp("preserved2/d/f");
        assert_eq!(mover(&f).unwrap(), Some(mp("repo1-rest/preserved2/d/f")));
        // `sub1` is a directory, remapped in repo2, but in repo1 is has
        // to be preserved
        let f = mp("sub1/f");
        assert_eq!(mover(&f).unwrap(), Some(f.clone()));
        // this is just a random file, not mentioned in either repo's configs
        // should be preserved, as repo1 has default_action preserve
        let f = mp("aeneas/was/a/lively/fellow");
        assert_eq!(mover(&f).unwrap(), Some(f.clone()));
    }

    #[test]
    fn test_get_small_to_large_mover_2_non_overlapping() {
        let large_sync_config = get_large_repo_sync_config_non_overlapping();
        let mover = get_small_to_large_mover(&large_sync_config, RepositoryId::new(2)).unwrap();

        // `preserved2` is a directory, preserved from repo2
        let f = mp("preserved2/f");
        assert_eq!(mover(&f).unwrap(), Some(mp("preserved2/f")));
        let f = mp("preserved2/d/f");
        assert_eq!(mover(&f).unwrap(), Some(mp("preserved2/d/f")));
        // `sub1` is a directory, remapped in repo2
        let f = mp("sub1/f");
        assert_eq!(mover(&f).unwrap(), Some(mp("repo2-rest/sub1/f")));
        let f = mp("sub2/d/f");
        assert_eq!(mover(&f).unwrap(), Some(mp("repo2-rest/sub2/d/f")));
        // this is just a random file, not mentioned in either repo's configs
        // should be shifted, as repo2 has default_action prepend prefix
        let f = mp("aeneas/was/a/lively/fellow");
        assert_eq!(
            mover(&f).unwrap(),
            Some(mp("shifted2/aeneas/was/a/lively/fellow"))
        );
    }

    #[test]
    fn test_get_large_to_small_mover_non_overlapping_images() {
        let large_sync_config = get_large_repo_sync_config_non_overlapping();
        let mover_1 = get_large_to_small_mover(&large_sync_config, RepositoryId::new(1)).unwrap();
        let mover_2 = get_large_to_small_mover(&large_sync_config, RepositoryId::new(2)).unwrap();

        // any changes to large repo's `preserved2` dir could only come
        // from repo 1
        let f = mp("preserved2/f");
        assert_eq!(mover_1(&f).unwrap(), None);
        assert_eq!(mover_2(&f).unwrap(), Some(mp("preserved2/f")));
        // any changes to large repo's `sub1` dir could only come from repo 1
        let f = mp("sub1/f");
        assert_eq!(mover_1(&f).unwrap(), Some(mp("sub1/f")));
        assert_eq!(mover_2(&f).unwrap(), None);
        // any changes to large repo's `repo1-rest/preserved2` dir could
        // only come from repo 1
        let f = mp("repo1-rest/preserved2/f");
        assert_eq!(mover_1(&f).unwrap(), Some(mp("preserved2/f")));
        assert_eq!(mover_2(&f).unwrap(), None);
        // any changes to large repo's `repo2-rest/sub1` dir could
        // only come from repo 2
        let f = mp("repo2-rest/sub1/f");
        assert_eq!(mover_1(&f).unwrap(), None);
        assert_eq!(mover_2(&f).unwrap(), Some(mp("sub1/f")));
        // any changes to large repo's `shifted2` dir could
        // only come from repo 2
        let f = mp("shifted2/f");
        assert_eq!(mover_1(&f).unwrap(), None);
        assert_eq!(mover_2(&f).unwrap(), Some(mp("f")));

        // Neither of the dirs below are remappings of any existing dir.
        // Neither `repo1-rest`, nor `repo2-rest` is a default
        // prependable prefix.
        // Changes to these dirs could only be preserved from repo 1
        let f = mp("repo1-rest/aeneas/was/a/lively/fellow");
        assert_eq!(
            mover_1(&f).unwrap(),
            Some(mp("repo1-rest/aeneas/was/a/lively/fellow"))
        );
        assert_eq!(mover_2(&f).unwrap(), None);
        let f = mp("repo2-rest/aeneas/was/a/lively/fellow");
        assert_eq!(
            mover_1(&f).unwrap(),
            Some(mp("repo2-rest/aeneas/was/a/lively/fellow"))
        );
        assert_eq!(mover_2(&f).unwrap(), None);
        let f = mp("aeneas/was/a/lively/fellow");
        assert_eq!(mover_1(&f).unwrap(), Some(mp("aeneas/was/a/lively/fellow")));
        assert_eq!(mover_2(&f).unwrap(), None);

        // There no correct way to behave when the file has the same
        // name as a prependable prefix. Generally we will prevent
        // introducting files like this in the first place, but let's
        // make sure the code does the right thing. Note that commit
        // containing changes to such file will succeed syncing to
        // a repo, which does not use this prefix. We may want to
        // change that too, but failing to sync to one of the small
        // repos should be a signal enough to us that this needs looking.
        let prefix_only = mp("shifted2");
        assert!(mover_2(&prefix_only).is_err());
        assert_eq!(mover_1(&prefix_only).unwrap(), None);
    }

    /*
    Below, the following sync config is tested:
    Small repo 1:
        (unmatched paths stay as they are)
        default action: preserve
        (a directory, which was preserved from repo2 is
        now preserved from both, i.e. it is identical)
        "preserved2" => "preserved2"
    Small repo 2:
        (unmatched paths go into "shifted2" subdir of a large repo)
        default action: prepend prefix "shifted2"
        (a single dir is preserved from repo2)
        "preserved2" => "preserved2"
        (some paths are moved into a different location)
        "sub1" => "repo2-rest/sub1"
        "sub2" => "repo2-rest/sub2"
    Note that in this configuration, the small repos have overlapping
    images in the big repo. Separate testing of small-to-large configs
    in this scenario is not needed, but the large-to-small sync is
    different in this case.
    */

    fn get_large_repo_sync_config_overlapping() -> CommitSyncConfig {
        CommitSyncConfig {
            large_repo_id: RepositoryId::new(3),
            common_pushrebase_bookmarks: vec![],
            small_repos: hashmap! {
                RepositoryId::new(1) => SmallRepoCommitSyncConfig {
                    default_action: DefaultSmallToLargeCommitSyncPathAction::Preserve,
                    map: hashmap! {
                        mp("preserved2") => mp("preserved2"),
                    },
                },
                RepositoryId::new(2) => SmallRepoCommitSyncConfig {
                    default_action: DefaultSmallToLargeCommitSyncPathAction::PrependPrefix(mp("shifted2")),
                    map: hashmap! {
                        mp("preserved2") => mp("preserved2"),
                        mp("sub1") => mp("repo2-rest/sub1"),
                        mp("sub2") => mp("repo2-rest/sub2"),
                    },
                },
            },
            version_name: CommitSyncConfigVersion("TEST_VERSION_NAME".to_string()),
        }
    }

    #[test]
    fn test_get_large_to_small_mover_overlapping_images() {
        let mover_1 = get_large_to_small_mover(
            &get_large_repo_sync_config_overlapping(),
            RepositoryId::new(1),
        )
        .unwrap();
        let mover_2 = get_large_to_small_mover(
            &get_large_repo_sync_config_overlapping(),
            RepositoryId::new(2),
        )
        .unwrap();
        // `preserved2` is an identical directory, we should replay changes
        // to it to both small repos
        let f = mp("preserved2/f");
        assert_eq!(mover_1(&f).unwrap(), Some(mp("preserved2/f")));
        assert_eq!(mover_2(&f).unwrap(), Some(mp("preserved2/f")));
        // any changes to large repo's `sub1` dir could only come from repo 1
        let f = mp("sub1/f");
        assert_eq!(mover_1(&f).unwrap(), Some(mp("sub1/f")));
        assert_eq!(mover_2(&f).unwrap(), None);
        // any changes to large repo's `repo1-rest/preserved2` dir could
        // only come from repo 1
        let f = mp("repo1-rest/preserved2/f");
        assert_eq!(mover_1(&f).unwrap(), Some(mp("repo1-rest/preserved2/f")));
        assert_eq!(mover_2(&f).unwrap(), None);
        // any changes to large repo's `repo2-rest/sub1` dir could
        // only come from repo 2
        let f = mp("repo2-rest/sub1/f");
        assert_eq!(mover_1(&f).unwrap(), None);
        assert_eq!(mover_2(&f).unwrap(), Some(mp("sub1/f")));
        // any changes to large repo's `shifted2` dir could
        // only come from repo 2
        let f = mp("shifted2/f");
        assert_eq!(mover_1(&f).unwrap(), None);
        assert_eq!(mover_2(&f).unwrap(), Some(mp("f")));

        // Neither of the dirs below are remappings of any existing dir.
        // Neither `repo1-rest`, nor `repo2-rest` is a default
        // prependable prefix.
        // Changes to these dirs could only be preserved from repo 1
        let f = mp("repo1-rest/aeneas/was/a/lively/fellow");
        assert_eq!(
            mover_1(&f).unwrap(),
            Some(mp("repo1-rest/aeneas/was/a/lively/fellow"))
        );
        assert_eq!(mover_2(&f).unwrap(), None);
        let f = mp("repo2-rest/aeneas/was/a/lively/fellow");
        assert_eq!(
            mover_1(&f).unwrap(),
            Some(mp("repo2-rest/aeneas/was/a/lively/fellow"))
        );
        assert_eq!(mover_2(&f).unwrap(), None);
        let f = mp("aeneas/was/a/lively/fellow");
        assert_eq!(mover_1(&f).unwrap(), Some(mp("aeneas/was/a/lively/fellow")));
        assert_eq!(mover_2(&f).unwrap(), None);

        // There no correct way to behave when the file has the same
        // name as a prependable prefix. Generally we will prevent
        // introducting files like this in the first place, but let's
        // make sure the code does the right thing. Note that commit
        // containing changes to such file will succeed syncing to
        // a repo, which does not use this prefix. We may want to
        // change that too, but failing to sync to one of the small
        // repos should be a signal enough to us that this needs looking.
        let prefix_only = mp("shifted2");
        assert!(mover_2(&prefix_only).is_err());
        assert_eq!(mover_1(&prefix_only).unwrap(), None);
    }

    fn get_small_repo_sync_config_non_prefix_free() -> SmallRepoCommitSyncConfig {
        SmallRepoCommitSyncConfig {
            default_action: DefaultSmallToLargeCommitSyncPathAction::PrependPrefix(mp("shifted2")),
            map: hashmap! {
                mp("preserved2") => mp("preserved2"),
                mp("sub1") => mp("repo2-rest/sub1"),
                mp("sub1/preserved") => mp("sub1/preserved"),
            },
        }
    }

    fn get_large_repo_sync_config_non_prefix_free() -> CommitSyncConfig {
        CommitSyncConfig {
            large_repo_id: RepositoryId::new(2),
            common_pushrebase_bookmarks: vec![],
            small_repos: hashmap! {
                RepositoryId::new(1) => get_small_repo_sync_config_non_prefix_free(),
            },
            version_name: CommitSyncConfigVersion("TEST_VERSION_NAME".to_string()),
        }
    }

    #[test]
    fn test_get_large_to_small_mover_non_prefix_free() -> Result<()> {
        let mover = get_large_to_small_mover(
            &get_large_repo_sync_config_non_prefix_free(),
            RepositoryId::new(1),
        )?;

        // `preserved2` is an identical directory, we should replay changes
        // to it to both small repos
        let f = mp("preserved2/f");
        assert_eq!(mover(&f)?, Some(mp("preserved2/f")));

        // This file is not from small repo, so should be remapped to None
        let f = mp("randomefile");
        assert_eq!(mover(&f)?, None);

        // Any changes to large repo's `sub1` dir could only come from repo 1
        let f = mp("repo2-rest/sub1/f");
        assert_eq!(mover(&f)?, Some(mp("sub1/f")));

        // This is an subtree of sub1, but this subtree is preserved. Make
        // sure path doesnt' change
        let f = mp("sub1/preserved");
        assert_eq!(mover(&f)?, Some(mp("sub1/preserved")));

        Ok(())
    }

    fn get_small_repo_sync_config_with_excludes() -> SmallRepoCommitSyncConfig {
        SmallRepoCommitSyncConfig {
            default_action: DefaultSmallToLargeCommitSyncPathAction::PrependPrefix(mp("shifted")),
            map: hashmap! {
                mp("preserved") => mp("preserved"),
                mp("preserved/excluded") => mp("shifted/preserved/excluded"),
            },
        }
    }

    fn get_large_repo_sync_config_with_exludes() -> CommitSyncConfig {
        CommitSyncConfig {
            large_repo_id: RepositoryId::new(2),
            common_pushrebase_bookmarks: vec![],
            small_repos: hashmap! {
                RepositoryId::new(1) => get_small_repo_sync_config_with_excludes(),
            },
            version_name: CommitSyncConfigVersion("TEST_VERSION_NAME".to_string()),
        }
    }

    #[test]
    fn test_get_large_to_small_mover_with_excludes() -> Result<()> {
        let mover = get_large_to_small_mover(
            &get_large_repo_sync_config_with_exludes(),
            RepositoryId::new(1),
        )?;

        let f = mp("shifted/f");
        assert_eq!(mover(&f)?, Some(mp("f")));

        let f = mp("shifted/preserved/1.txt");
        assert_eq!(mover(&f)?, None);

        let f = mp("shifted/preserved/excluded/1");
        assert_eq!(mover(&f)?, Some(mp("preserved/excluded/1")));

        let f = mp("preserved/excluded/1");
        assert_eq!(mover(&f)?, None);

        Ok(())
    }
}