/*
 * 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::anyhow;
use anyhow::Error;
use blobrepo::BlobRepo;
use blobstore::Loadable;
use bookmarks::BookmarkKey;
use bookmarks::BookmarksRef;
use context::CoreContext;
use derived_data::BonsaiDerived;
use futures::future;
use futures::future::try_join;
use futures::TryStreamExt;
use itertools::Itertools;
use manifest::Diff;
use manifest::ManifestOps;
use maplit::hashset;
use megarepolib::common::create_and_save_bonsai;
use megarepolib::common::ChangesetArgsFactory;
use megarepolib::common::StackPosition;
use mercurial_derivation::DeriveHgChangeset;
use metaconfig_types::PushrebaseFlags;
use mononoke_types::ChangesetId;
use mononoke_types::FileChange;
use mononoke_types::MPath;
use pushrebase::do_pushrebase_bonsai;
use regex::Regex;
use repo_blobstore::RepoBlobstoreRef;
use slog::error;
use slog::info;
use tokio::time::sleep;
use unodes::RootUnodeManifestId;

pub async fn create_deletion_head_commits<'a>(
    ctx: &'a CoreContext,
    repo: &'a BlobRepo,
    head_bookmark: BookmarkKey,
    commit_to_merge: ChangesetId,
    path_regex: Regex,
    deletion_chunk_size: usize,
    cs_args_factory: Box<dyn ChangesetArgsFactory>,
    pushrebase_flags: &'a PushrebaseFlags,
    wait_secs: u64,
) -> Result<(), Error> {
    let files =
        find_files_that_need_to_be_deleted(ctx, repo, &head_bookmark, commit_to_merge, path_regex)
            .await?;

    info!(ctx.logger(), "total files to delete is {}", files.len());
    for (num, chunk) in files
        .into_iter()
        .chunks(deletion_chunk_size)
        .into_iter()
        .enumerate()
    {
        let files = chunk
            .into_iter()
            .map(|path| (path, FileChange::Deletion))
            .collect();
        let maybe_head_bookmark_val = repo.bookmarks().get(ctx.clone(), &head_bookmark).await?;
        let head_bookmark_val =
            maybe_head_bookmark_val.ok_or_else(|| anyhow!("{} not found", head_bookmark))?;

        let bcs_id = create_and_save_bonsai(
            ctx,
            repo,
            vec![head_bookmark_val],
            files,
            cs_args_factory(StackPosition(num)),
        )
        .await?;
        info!(
            ctx.logger(),
            "created bonsai #{}. Deriving hg changeset for it to verify its correctness", num
        );
        let hg_cs_id = repo.derive_hg_changeset(ctx, bcs_id).await?;

        info!(ctx.logger(), "derived {}, pushrebasing...", hg_cs_id);

        let bcs = bcs_id.load(ctx, repo.repo_blobstore()).await?;
        let pushrebase_res = do_pushrebase_bonsai(
            ctx,
            repo,
            pushrebase_flags,
            &head_bookmark,
            &hashset![bcs],
            &[],
        )
        .await?;
        info!(ctx.logger(), "Pushrebased to {}", pushrebase_res.head);
        if wait_secs > 0 {
            info!(ctx.logger(), "waiting for {} seconds", wait_secs);
            sleep(Duration::from_secs(wait_secs)).await;
        }
    }

    Ok(())
}

pub async fn validate(
    ctx: &CoreContext,
    repo: &BlobRepo,
    head_commit: ChangesetId,
    to_merge_commit: ChangesetId,
    path_regex: Regex,
) -> Result<(), Error> {
    let head_root_unode = RootUnodeManifestId::derive(ctx, repo, head_commit);
    let to_merge_commit_root_unode = RootUnodeManifestId::derive(ctx, repo, to_merge_commit);

    let (head_root_unode, to_merge_commit_root_unode) =
        try_join(head_root_unode, to_merge_commit_root_unode).await?;

    let head_leaves = head_root_unode
        .manifest_unode_id()
        .list_leaf_entries(ctx.clone(), repo.repo_blobstore().clone())
        .try_collect::<Vec<_>>();
    let to_merge_commit_leaves = to_merge_commit_root_unode
        .manifest_unode_id()
        .list_leaf_entries(ctx.clone(), repo.repo_blobstore().clone())
        .try_collect::<Vec<_>>();

    let (head_leaves, mut to_merge_commit_leaves) =
        try_join(head_leaves, to_merge_commit_leaves).await?;

    info!(
        ctx.logger(),
        "total unodes in head commit: {}",
        head_leaves.len()
    );
    info!(
        ctx.logger(),
        "total unodes in to merge commit: {}",
        to_merge_commit_leaves.len()
    );
    let mut head_leaves = head_leaves
        .into_iter()
        .filter(|(path, _)| path.matches_regex(&path_regex))
        .collect::<Vec<_>>();
    info!(
        ctx.logger(),
        "unodes in to head commit after filtering: {}",
        head_leaves.len()
    );

    head_leaves.sort();
    to_merge_commit_leaves.sort();

    if head_leaves == to_merge_commit_leaves {
        info!(ctx.logger(), "all is well");
    } else {
        error!(ctx.logger(), "validation failed!");
        for (path, unode) in head_leaves {
            println!("{}\t{}\t{}", head_commit, path, unode);
        }

        for (path, unode) in to_merge_commit_leaves {
            println!("{}\t{}\t{}", to_merge_commit, path, unode);
        }
    }
    Ok(())
}

// Returns paths of the files that:
// 1) Match `path_regex`
// 2) Either do not exist in `commit_to_merge` or have different content/filetype.
async fn find_files_that_need_to_be_deleted(
    ctx: &CoreContext,
    repo: &BlobRepo,
    head_bookmark: &BookmarkKey,
    commit_to_merge: ChangesetId,
    path_regex: Regex,
) -> Result<Vec<MPath>, Error> {
    let maybe_head_bookmark_val = repo.bookmarks().get(ctx.clone(), head_bookmark).await?;

    let head_bookmark_val =
        maybe_head_bookmark_val.ok_or_else(|| anyhow!("{} not found", head_bookmark))?;

    let head_root_unode = RootUnodeManifestId::derive(ctx, repo, head_bookmark_val);
    let commit_to_merge_root_unode = RootUnodeManifestId::derive(ctx, repo, commit_to_merge);

    let (head_root_unode, commit_to_merge_root_unode) =
        try_join(head_root_unode, commit_to_merge_root_unode).await?;

    let mut paths = head_root_unode
        .manifest_unode_id()
        .diff(
            ctx.clone(),
            repo.repo_blobstore().clone(),
            *commit_to_merge_root_unode.manifest_unode_id(),
        )
        .try_filter_map(|diff| async move {
            use Diff::*;
            let maybe_path = match diff {
                Added(_maybe_path, _entry) => None,
                Removed(maybe_path, entry) => entry.into_leaf().and(maybe_path),
                Changed(maybe_path, _old_entry, new_entry) => new_entry.into_leaf().and(maybe_path),
            };

            Ok(maybe_path)
        })
        .try_filter(|path| future::ready(path.matches_regex(&path_regex)))
        .try_collect::<Vec<_>>()
        .await?;

    paths.sort();
    Ok(paths)
}

#[cfg(test)]
mod test {
    use commit_graph::CommitGraphRef;
    use fbinit::FacebookInit;
    use futures::StreamExt;
    use megarepolib::common::ChangesetArgs;
    use mononoke_types::DateTime;
    use tests_utils::bookmark;
    use tests_utils::resolve_cs_id;
    use tests_utils::CreateCommitContext;

    use super::*;

    const PATH_REGEX: &str = "^(unchanged/.*|changed/.*|toremove/.*)";

    #[fbinit::test]
    async fn test_find_files_that_needs_to_be_deleted(fb: FacebookInit) -> Result<(), Error> {
        let ctx = CoreContext::test_mock(fb);
        let repo = prepare_repo(&ctx).await?;

        let commit_to_merge = resolve_cs_id(&ctx, &repo, "commit_to_merge").await?;
        let book = BookmarkKey::new("book")?;
        let mut paths = find_files_that_need_to_be_deleted(
            &ctx,
            &repo,
            &book,
            commit_to_merge,
            Regex::new(PATH_REGEX)?,
        )
        .await?;

        paths.sort();
        assert_eq!(
            paths,
            vec![
                MPath::new("changed/a")?,
                MPath::new("changed/b")?,
                MPath::new("toremove/file1")?,
                MPath::new("toremove/file2")?,
            ]
        );

        Ok(())
    }

    #[fbinit::test]
    async fn test_find_changed_files_with_revert(fb: FacebookInit) -> Result<(), Error> {
        let ctx = CoreContext::test_mock(fb);

        let repo: BlobRepo = test_repo_factory::build_empty(fb).await?;

        let root_commit = CreateCommitContext::new_root(&ctx, &repo)
            .add_file("file", "a")
            .commit()
            .await?;

        // Change file content and then revert it back to existing value
        let head_commit = CreateCommitContext::new(&ctx, &repo, vec![root_commit])
            .add_file("file", "b")
            .commit()
            .await?;
        let head_commit = CreateCommitContext::new(&ctx, &repo, vec![head_commit])
            .add_file("file", "a")
            .commit()
            .await?;
        bookmark(&ctx, &repo, "book").set_to(head_commit).await?;

        let commit_to_merge = CreateCommitContext::new(&ctx, &repo, vec![root_commit])
            .commit()
            .await?;

        let book = BookmarkKey::new("book")?;
        let mut paths = find_files_that_need_to_be_deleted(
            &ctx,
            &repo,
            &book,
            commit_to_merge,
            Regex::new(".*")?,
        )
        .await?;

        paths.sort();
        assert_eq!(paths, vec![MPath::new("file")?,]);

        Ok(())
    }

    #[fbinit::test]
    async fn test_create_deletion_head_commits(fb: FacebookInit) -> Result<(), Error> {
        let ctx = CoreContext::test_mock(fb);
        let repo = prepare_repo(&ctx).await?;
        let book = BookmarkKey::new("book")?;

        let commit_to_merge = resolve_cs_id(&ctx, &repo, "commit_to_merge").await?;
        let args_factory = Box::new(|stack_pos: StackPosition| ChangesetArgs {
            author: "author".to_string(),
            message: format!("{}", stack_pos.0),
            datetime: DateTime::now(),
            bookmark: None,
            mark_public: false,
        });

        let pushrebase_flags = PushrebaseFlags {
            rewritedates: true,
            forbid_p2_root_rebases: true,
            casefolding_check: true,
            recursion_limit: None,
            ..Default::default()
        };

        let commit_before_push = resolve_cs_id(&ctx, &repo, book.clone()).await?;
        create_deletion_head_commits(
            &ctx,
            &repo,
            book.clone(),
            commit_to_merge,
            Regex::new(PATH_REGEX)?,
            1,
            args_factory,
            &pushrebase_flags,
            0,
        )
        .await?;
        let commit_after_push = resolve_cs_id(&ctx, &repo, book.clone()).await?;

        let range_len = repo
            .commit_graph()
            .range_stream(&ctx, commit_before_push, commit_after_push)
            .await?
            .count()
            .await;

        // 4 new commits + commit_before_push
        assert_eq!(range_len, 4 + 1);

        let paths = find_files_that_need_to_be_deleted(
            &ctx,
            &repo,
            &book,
            commit_to_merge,
            Regex::new(PATH_REGEX)?,
        )
        .await?;

        assert!(paths.is_empty());
        Ok(())
    }

    async fn prepare_repo(ctx: &CoreContext) -> Result<BlobRepo, Error> {
        let repo: BlobRepo = test_repo_factory::build_empty(ctx.fb).await?;

        let root_commit = CreateCommitContext::new_root(ctx, &repo)
            .add_file("unchanged/a", "a")
            .commit()
            .await?;

        let head_commit = CreateCommitContext::new(ctx, &repo, vec![root_commit])
            .add_file("unrelated_file", "a")
            .add_file("changed/a", "oldcontent")
            .add_file("changed/b", "oldcontent")
            .add_file("toremove/file1", "content")
            .add_file("toremove/file2", "content")
            .commit()
            .await?;

        let commit_to_merge = CreateCommitContext::new(ctx, &repo, vec![root_commit])
            .add_file("changed/a", "newcontent")
            .add_file("changed/b", "newcontent")
            .commit()
            .await?;

        bookmark(ctx, &repo, "book").set_to(head_commit).await?;
        bookmark(ctx, &repo, "commit_to_merge")
            .set_to(commit_to_merge)
            .await?;

        Ok(repo)
    }
}


/*
 * 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::format_err;
use anyhow::Error;
use bookmarks::BookmarkKey;
use clap::App;
use clap::Arg;
use clap::ArgGroup;
use clap::ArgMatches;
use clap::SubCommand;
use cmdlib::args;
use cmdlib::args::MononokeClapApp;
use futures_ext::try_boxfuture;
use futures_ext::BoxFuture;
use futures_ext::FutureExt;
use futures_old::future::err;
use futures_old::future::ok;
use megarepolib::common::ChangesetArgs;
use megarepolib::common::ChangesetArgsFactory;
use megarepolib::common::StackPosition;
use mononoke_types::DateTime;

pub const BACKFILL_NOOP_MAPPING: &str = "backfill-noop-mapping";
pub const BASE_COMMIT_HASH: &str = "base-commit-hash";
pub const BONSAI_MERGE_P1: &str = "bonsai-merge-p1";
pub const BONSAI_MERGE_P2: &str = "bonsai-merge-p2";
pub const BONSAI_MERGE: &str = "bonsai-merge";
pub const CATCHUP_DELETE_HEAD: &str = "create-catchup-head-deletion-commits";
pub const CATCHUP_VALIDATE_COMMAND: &str = "catchup-validate";
pub const CHANGESET: &str = "commit";
pub const CHECK_PUSH_REDIRECTION_PREREQS: &str = "check-push-redirection-prereqs";
pub const CHUNKING_HINT_FILE: &str = "chunking-hint-file";
pub const COMMIT_AUTHOR: &str = "commit-author";
pub const COMMIT_BOOKMARK: &str = "bookmark";
pub const COMMIT_DATE_RFC3339: &str = "commit-date-rfc3339";
pub const COMMIT_HASH: &str = "commit-hash";
pub const COMMIT_HASH_CORRECT_HISTORY: &str = "commit-hash-correct-history";
pub const COMMIT_MESSAGE: &str = "commit-message";
pub const DELETE_NO_LONGER_BOUND_FILES_FROM_LARGE_REPO: &str =
    "delete-no-longer-bound-files-from-large-repo";
pub const DELETION_CHUNK_SIZE: &str = "deletion-chunk-size";
pub const DIFF_MAPPING_VERSIONS: &str = "diff-mapping-versions";
pub const DRY_RUN: &str = "dry-run";
pub const EVEN_CHUNK_SIZE: &str = "even-chunk-size";
pub const FIRST_PARENT: &str = "first-parent";
pub const GRADUAL_MERGE_PROGRESS: &str = "gradual-merge-progress";
pub const GRADUAL_MERGE: &str = "gradual-merge";
pub const GRADUAL_DELETE: &str = "gradual-delete";
pub const HEAD_BOOKMARK: &str = "head-bookmark";
pub const HISTORY_FIXUP_DELETE: &str = "history-fixup-deletes";
pub const INPUT_FILE: &str = "input-file";
pub const LAST_DELETION_COMMIT: &str = "last-deletion-commit";
pub const LIMIT: &str = "limit";
pub const MANUAL_COMMIT_SYNC: &str = "manual-commit-sync";
pub const MAPPING_VERSION_NAME: &str = "mapping-version-name";
pub const MARK_NOT_SYNCED_COMMAND: &str = "mark-not-synced";
pub const MARK_PUBLIC: &str = "mark-public";
pub const MAX_NUM_OF_MOVES_IN_COMMIT: &str = "max-num-of-moves-in-commit";
pub const MERGE: &str = "merge";
pub const MOVE: &str = "move";
pub const ORIGIN_REPO: &str = "origin-repo";
pub const OVERWRITE: &str = "overwrite";
pub const PARENTS: &str = "parents";
pub const PATH_REGEX: &str = "path-regex";
pub const PATH: &str = "path";
pub const PATH_PREFIX: &str = "path-prefix";
pub const PATHS_FILE: &str = "paths-file";
pub const PRE_DELETION_COMMIT: &str = "pre-deletion-commit";
pub const PRE_MERGE_DELETE: &str = "pre-merge-delete";
pub const RUN_MOVER: &str = "run-mover";
pub const SECOND_PARENT: &str = "second-parent";
pub const SELECT_PARENTS_AUTOMATICALLY: &str = "select-parents-automatically";
pub const SOURCE_CHANGESET: &str = "source-changeset";
pub const SYNC_COMMIT_AND_ANCESTORS: &str = "sync-commit-and-ancestors";
pub const SYNC_DIAMOND_MERGE: &str = "sync-diamond-merge";
pub const TARGET_CHANGESET: &str = "target-changeset";
pub const TO_MERGE_CS_ID: &str = "to-merge-cs-id";
pub const VERSION: &str = "version";
pub const WAIT_SECS: &str = "wait-secs";

pub fn cs_args_from_matches<'a>(sub_m: &ArgMatches<'a>) -> BoxFuture<ChangesetArgs, Error> {
    let message = try_boxfuture!(
        sub_m
            .value_of(COMMIT_MESSAGE)
            .ok_or_else(|| format_err!("missing argument {}", COMMIT_MESSAGE))
    )
    .to_string();
    let author = try_boxfuture!(
        sub_m
            .value_of(COMMIT_AUTHOR)
            .ok_or_else(|| format_err!("missing argument {}", COMMIT_AUTHOR))
    )
    .to_string();
    let datetime = try_boxfuture!(
        sub_m
            .value_of(COMMIT_DATE_RFC3339)
            .map_or_else(|| Ok(DateTime::now()), DateTime::from_rfc3339)
    );
    let bookmark = try_boxfuture!(
        sub_m
            .value_of(COMMIT_BOOKMARK)
            .map(BookmarkKey::new)
            .transpose()
    );
    let mark_public = sub_m.is_present(MARK_PUBLIC);
    if !mark_public && bookmark.is_some() {
        return err(format_err!(
            "--mark-public is required if --bookmark is provided"
        ))
        .boxify();
    }

    ok(ChangesetArgs {
        author,
        message,
        datetime,
        bookmark,
        mark_public,
    })
    .boxify()
}

pub fn get_delete_commits_cs_args_factory<'a>(
    sub_m: &ArgMatches<'a>,
) -> Result<Box<dyn ChangesetArgsFactory>, Error> {
    get_commit_factory(sub_m, |s, num| -> String {
        format!("[MEGAREPO DELETE] {} ({})", s, num)
    })
}

pub fn get_catchup_head_delete_commits_cs_args_factory<'a>(
    sub_m: &ArgMatches<'a>,
) -> Result<Box<dyn ChangesetArgsFactory>, Error> {
    get_commit_factory(sub_m, |s, num| -> String {
        format!("[MEGAREPO CATCHUP DELETE] {} ({})", s, num)
    })
}

pub fn get_gradual_merge_commits_cs_args_factory<'a>(
    sub_m: &ArgMatches<'a>,
) -> Result<Box<dyn ChangesetArgsFactory>, Error> {
    get_commit_factory(sub_m, |s, num| -> String {
        format!("[MEGAREPO GRADUAL MERGE] {} ({})", s, num)
    })
}

fn get_commit_factory<'a>(
    sub_m: &ArgMatches<'a>,
    msg_factory: impl Fn(&String, usize) -> String + Send + Sync + 'static,
) -> Result<Box<dyn ChangesetArgsFactory>, Error> {
    let message = sub_m
        .value_of(COMMIT_MESSAGE)
        .ok_or_else(|| format_err!("missing argument {}", COMMIT_MESSAGE))?
        .to_string();

    let author = sub_m
        .value_of(COMMIT_AUTHOR)
        .ok_or_else(|| format_err!("missing argument {}", COMMIT_AUTHOR))?
        .to_string();

    let datetime = sub_m
        .value_of(COMMIT_DATE_RFC3339)
        .map(DateTime::from_rfc3339)
        .transpose()?
        .unwrap_or_else(DateTime::now);

    Ok(Box::new(move |num: StackPosition| ChangesetArgs {
        author: author.clone(),
        message: msg_factory(&message, num.0),
        datetime: datetime.clone(),
        bookmark: None,
        mark_public: false,
    }))
}

fn add_resulting_commit_args<'a, 'b>(subcommand: App<'a, 'b>) -> App<'a, 'b> {
    subcommand
        .arg(
            Arg::with_name(COMMIT_AUTHOR)
                .help("commit author to use")
                .takes_value(true)
                .required(true),
        )
        .arg(
            Arg::with_name(COMMIT_MESSAGE)
                .help("commit message to use")
                .takes_value(true)
                .required(true),
        )
        .arg(
            Arg::with_name(MARK_PUBLIC)
                .help("add the resulting commit to the public phase")
                .long(MARK_PUBLIC),
        )
        .arg(
            Arg::with_name(COMMIT_DATE_RFC3339)
                .help("commit date to use (default is now)")
                .long(COMMIT_DATE_RFC3339)
                .takes_value(true),
        )
        .arg(
            Arg::with_name(COMMIT_BOOKMARK)
                .help("bookmark to point to resulting commits (no sanity checks, will move existing bookmark, be careful)")
                .long(COMMIT_BOOKMARK)
                .takes_value(true)
        )
}

fn add_light_resulting_commit_args<'a, 'b>(subcommand: App<'a, 'b>) -> App<'a, 'b> {
    subcommand
        .arg(
            Arg::with_name(COMMIT_AUTHOR)
                .help("commit author to use")
                .takes_value(true)
                .required(true),
        )
        .arg(
            Arg::with_name(COMMIT_MESSAGE)
                .help("commit message to use")
                .takes_value(true)
                .required(true),
        )
        .arg(
            Arg::with_name(COMMIT_DATE_RFC3339)
                .help("commit date to use (default is now)")
                .long(COMMIT_DATE_RFC3339)
                .takes_value(true),
        )
}

pub fn setup_app<'a, 'b>() -> MononokeClapApp<'a, 'b> {
    let move_subcommand = SubCommand::with_name(MOVE)
        .about("create a move commit, using a provided spec")
        .arg(
            Arg::with_name(MAX_NUM_OF_MOVES_IN_COMMIT)
                .long(MAX_NUM_OF_MOVES_IN_COMMIT)
                .help("how many files a single commit moves (note - that might create a stack of move commits instead of just one)")
                .takes_value(true)
                .required(false),
        )
        .arg(
            Arg::with_name(MAPPING_VERSION_NAME)
                .long(MAPPING_VERSION_NAME)
                .help("which mapping version to use when remapping from small to large repo")
                .takes_value(true)
                .required(true),
        )
        .arg(
            Arg::with_name(ORIGIN_REPO)
                .help("use predefined mover for part of megarepo, coming from this repo")
                .takes_value(true)
                .required(true),
        )
        .arg(
            Arg::with_name(CHANGESET)
                .help("a changeset hash or bookmark of move commit's parent")
                .takes_value(true)
                .required(true),
        );

    let merge_subcommand = SubCommand::with_name(MERGE)
        .about("create a merge commit with given parents")
        .arg(
            Arg::with_name(FIRST_PARENT)
                .help("first parent of a produced merge commit")
                .takes_value(true)
                .required(true),
        )
        .arg(
            Arg::with_name(SECOND_PARENT)
                .help("second parent of a produced merge commit")
                .takes_value(true)
                .required(true),
        );

    let sync_diamond_subcommand = SubCommand::with_name(SYNC_DIAMOND_MERGE)
        .about("sync a diamond merge commit from a small repo into large repo")
        .arg(
            Arg::with_name(COMMIT_HASH)
                .help("diamond merge commit from small repo to sync")
                .takes_value(true)
                .required(true),
        )
        .arg(
            Arg::with_name(COMMIT_BOOKMARK)
                .help("bookmark to point to resulting commits (no sanity checks, will move existing bookmark, be careful)")
                .long(COMMIT_BOOKMARK)
                .takes_value(true)
        );

    let pre_merge_delete_subcommand = SubCommand::with_name(PRE_MERGE_DELETE)
        .about("create a set of pre-merge delete commits (which remove all of the files in working copy)")
        .arg(
            Arg::with_name(COMMIT_HASH)
                .help("commit from which to start deletion")
                .takes_value(true)
                .required(true)
        )
        .arg(
            Arg::with_name(CHUNKING_HINT_FILE)
                .help(r#"a path to working copy chunking hint. If not provided, working copy will
                        be chunked evenly into `--even-chunk-size` commits"#)
                .long(CHUNKING_HINT_FILE)
                .takes_value(true)
                .required(false)
        )
        .arg(
            Arg::with_name(EVEN_CHUNK_SIZE)
                .help("chunk size for even chunking when --chunking-hing-file is not provided")
                .long(EVEN_CHUNK_SIZE)
                .takes_value(true)
                .required(false)
        )
        .arg(
            Arg::with_name(BASE_COMMIT_HASH)
                .help("commit that will be diffed against to find what files needs to be deleted - \
                 only files that don't exist or differ from base commit will be deleted.")
                .long(BASE_COMMIT_HASH)
                .takes_value(true)
                .required(false)
        );

    let history_fixup_delete_subcommand =
        add_light_resulting_commit_args(SubCommand::with_name(HISTORY_FIXUP_DELETE))
            .about("create a set of delete commits before the path fixup.")
            .arg(
                Arg::with_name(COMMIT_HASH)
                    .help(
                        "commit which we want to fixup (the
                         files specified in paths file will be deleted there)",
                    )
                    .takes_value(true)
                    .required(true),
            )
            .arg(
                Arg::with_name(COMMIT_HASH_CORRECT_HISTORY)
                    .help(
                        "commit hash containing the files with correct
                         history (the files specified in path files will be
                         preserved there; all the other files will be deleted)",
                    )
                    .takes_value(true)
                    .required(true),
            )
            .arg(
                Arg::with_name(EVEN_CHUNK_SIZE)
                    .help("chunk size for even chunking")
                    .long(EVEN_CHUNK_SIZE)
                    .takes_value(true)
                    .required(true),
            )
            .arg(
                Arg::with_name(PATHS_FILE)
                    .long(PATHS_FILE)
                    .help("file containing paths to fixup separated by newlines")
                    .takes_value(true)
                    .required(true)
                    .multiple(true),
            );

    // PLease don't move `add_light_resulting_commit_args` to be applied
    // after `PATH` arg is added, as in that case `PATH` won't be the last
    // positional argument
    let gradual_delete_subcommand =
        add_light_resulting_commit_args(SubCommand::with_name(GRADUAL_DELETE))
            .about("create a set of delete commits for given paths")
            .arg(
                Arg::with_name(COMMIT_HASH)
                    .help("commit from which to start deletion")
                    .takes_value(true)
                    .required(true),
            )
            .arg(
                Arg::with_name(EVEN_CHUNK_SIZE)
                    .help("chunk size for even chunking")
                    .long(EVEN_CHUNK_SIZE)
                    .takes_value(true)
                    .required(true),
            )
            .arg(
                Arg::with_name(PATH)
                    .help("paths to delete")
                    .takes_value(true)
                    .required(true)
                    .multiple(true),
            );

    let bonsai_merge_subcommand = SubCommand::with_name(BONSAI_MERGE)
        .about("create a bonsai merge commit")
        .arg(
            Arg::with_name(BONSAI_MERGE_P1)
                .help("p1 of the merge")
                .takes_value(true)
                .required(true),
        )
        .arg(
            Arg::with_name(BONSAI_MERGE_P2)
                .help("p2 of the merge")
                .takes_value(true)
                .required(true),
        );

    let gradual_merge_subcommand = SubCommand::with_name(GRADUAL_MERGE)
        .about("Gradually merge a list of deletion commits")
        .arg(
            Arg::with_name(LAST_DELETION_COMMIT)
                .long(LAST_DELETION_COMMIT)
                .help("Last deletion commit")
                .takes_value(true)
                .required(true),
        )
        .arg(
            Arg::with_name(PRE_DELETION_COMMIT)
                .long(PRE_DELETION_COMMIT)
                .help("Commit right before the first deletion commit")
                .takes_value(true)
                .required(true),
        )
        .arg(
            Arg::with_name(COMMIT_BOOKMARK)
                .help("bookmark to point to resulting commits (no sanity checks, will move existing bookmark, be careful)")
                .long(COMMIT_BOOKMARK)
                .takes_value(true)
        )
        .arg(
            Arg::with_name(DRY_RUN)
                .long(DRY_RUN)
                .help("Dry-run mode - doesn't do a merge, just validates")
                .takes_value(false)
                .required(false),
        )
        .arg(
            Arg::with_name(LIMIT)
                .long(LIMIT)
                .help("how many commits to merge")
                .takes_value(true)
                .required(false),
        );

    let gradual_merge_progress_subcommand = SubCommand::with_name(GRADUAL_MERGE_PROGRESS)
        .about("Display progress of the gradual merge as #MERGED_COMMITS/#TOTAL_COMMITS_TO_MERGE")
        .arg(
            Arg::with_name(LAST_DELETION_COMMIT)
                .long(LAST_DELETION_COMMIT)
                .help("Last deletion commit")
                .takes_value(true)
                .required(true),
        )
        .arg(
            Arg::with_name(PRE_DELETION_COMMIT)
                .long(PRE_DELETION_COMMIT)
                .help("Commit right before the first deletion commit")
                .takes_value(true)
                .required(true),
        )
        .arg(
            Arg::with_name(COMMIT_BOOKMARK)
                .help("bookmark to point to resulting commits (no sanity checks, will move existing bookmark, be careful)")
                .long(COMMIT_BOOKMARK)
                .takes_value(true)
        );

    let manual_commit_sync_subcommand = SubCommand::with_name(MANUAL_COMMIT_SYNC)
        .about("Manually sync a commit from source repo to a target repo. It's usually used right after a big merge")
        .arg(
            Arg::with_name(CHANGESET)
                .long(CHANGESET)
                .help("Source repo changeset that will synced to target repo")
                .takes_value(true)
                .required(true),
        )
        .arg(
            Arg::with_name(DRY_RUN)
                .long(DRY_RUN)
                .help("Dry-run mode - doesn't do a merge, just validates")
                .takes_value(false)
                .required(false),
        )
        .arg(
            Arg::with_name(PARENTS)
                .long(PARENTS)
                .help("Parents of the new commit")
                .takes_value(true)
                .multiple(true)
        )
        .arg(
            Arg::with_name(SELECT_PARENTS_AUTOMATICALLY)
                .long(SELECT_PARENTS_AUTOMATICALLY)
                .help("Finds parents automatically: takes parents in the source repo and finds equivalent commits in target repo. \
                If parents are not remapped yet then this command will fail")
                .takes_value(false)
        )
        .arg(
            Arg::with_name(MAPPING_VERSION_NAME)
                .long(MAPPING_VERSION_NAME)
                .help("name of the noop mapping that will be inserted")
                .takes_value(true)
                .required(true),
        )
        .group(
            ArgGroup::with_name("parents_group")
                .args(&[SELECT_PARENTS_AUTOMATICALLY, PARENTS])
                .required(true)
        );

    let catchup_delete_head_subcommand = SubCommand::with_name(CATCHUP_DELETE_HEAD)
        .about("Create delete commits for 'catchup strategy. \
        This is normally used after invisible merge is done, but small repo got a few new commits
        that needs merging.

        O         <-  head bookmark
        |
        O   O <-  new commits (we want to merge them in master)
        |  ...
        IM  |       <- invisible merge commit
        |\\ /
        O O

        This command create deletion commits on top of master bookmark for files that were changed in new commits,
        and pushrebases them.

        After all of the commits are pushrebased paths that match --path-regex in head bookmark should be a subset
        of all paths that match --path-regex in the latest new commit we want to merge.
        ")
        .arg(
            Arg::with_name(HEAD_BOOKMARK)
                .long(HEAD_BOOKMARK)
                .help("commit to merge into")
                .takes_value(true)
                .required(true),
        )
        .arg(
            Arg::with_name(TO_MERGE_CS_ID)
                .long(TO_MERGE_CS_ID)
                .help("commit to merge")
                .takes_value(true)
                .required(true),
        )
        .arg(
            Arg::with_name(PATH_REGEX)
                .long(PATH_REGEX)
                .help("regex that matches all paths that should be merged in head commit")
                .takes_value(true)
                .required(true),
        )
        .arg(
            Arg::with_name(DELETION_CHUNK_SIZE)
                .long(DELETION_CHUNK_SIZE)
                .help("how many files to delete in a single commit")
                .default_value("10000")
                .takes_value(true)
                .required(false),
        )
        .arg(
            Arg::with_name(WAIT_SECS)
                .long(WAIT_SECS)
                .help("how many seconds to wait after each push")
                .default_value("0")
                .takes_value(true)
                .required(false),
        );

    let catchup_validate_subcommand = SubCommand::with_name(CATCHUP_VALIDATE_COMMAND)
        .about("validate invariants about the catchup")
        .arg(
            Arg::with_name(COMMIT_HASH)
                .long(COMMIT_HASH)
                .help("merge commit i.e. commit where all catchup commits were merged into")
                .takes_value(true)
                .required(true),
        )
        .arg(
            Arg::with_name(TO_MERGE_CS_ID)
                .long(TO_MERGE_CS_ID)
                .help("commit to merge")
                .takes_value(true)
                .required(true),
        )
        .arg(
            Arg::with_name(PATH_REGEX)
                .long(PATH_REGEX)
                .help("regex that matches all paths that should be merged in head commit")
                .takes_value(true)
                .required(true),
        );

    let mark_not_synced_candidate = SubCommand::with_name(MARK_NOT_SYNCED_COMMAND)
        .about("mark all commits that do not have any mapping as not synced candidate, but leave those that have the mapping alone")
        .arg(
            Arg::with_name(MAPPING_VERSION_NAME)
                .help("a version to use")
                .takes_value(true)
                .required(true),
        )
        .arg(
            Arg::with_name(INPUT_FILE)
                .long(INPUT_FILE)
                .help("list of large repo commit hashes that should be considered to be marked as not sync candidate")
                .takes_value(true)
                .required(true)
        )
        .arg(
            Arg::with_name(OVERWRITE)
                .long(OVERWRITE)
                .help("whether to overwrite existing values or not")
                .takes_value(false)
                .required(false),
        );

    let check_push_redirection_prereqs_subcommand = SubCommand::with_name(CHECK_PUSH_REDIRECTION_PREREQS)
        .about("check the prerequisites of enabling push-redirection at a given commit with a given CommitSyncConfig version")
        .arg(
            Arg::with_name(SOURCE_CHANGESET)
                .help("a source changeset hash or bookmark to check")
                .takes_value(true)
                .required(true),
        )
        .arg(
            Arg::with_name(TARGET_CHANGESET)
                .help("a target changeset hash or bookmark to check")
                .takes_value(true)
                .required(true),
        )
        .arg(
            Arg::with_name(VERSION)
                .help("a version to use")
                .takes_value(true)
                .required(true),
        );

    let run_mover_subcommand = SubCommand::with_name(RUN_MOVER)
        .about("run mover of a given version to remap paths between source and target repos")
        .arg(
            Arg::with_name(VERSION)
                .help("a version to use")
                .takes_value(true)
                .required(true),
        )
        .arg(
            Arg::with_name(PATH)
                .help("a path to remap")
                .takes_value(true)
                .required(true),
        );

    let backfill_noop_mapping = SubCommand::with_name(BACKFILL_NOOP_MAPPING)
        .about(
            "
            Given the list of commit identifiers resolve them to bonsai hashes in source \
            and target repo and insert a sync commit mapping with specified version name. \
            This is useful for initial backfill to mark commits that are identical between \
            repositories. \
            Input file can contain any commit identifier (e.g. bookmark name) \
            but the safest approach is to use commit hashes (bonsai or hg). \
            'source-repo' argument represents the small repo while 'target-repo' is the large repo.
        ",
        )
        .arg(
            Arg::with_name(INPUT_FILE)
                .long(INPUT_FILE)
                .help("list of commit hashes which are remapped with noop mapping")
                .takes_value(true)
                .required(true),
        )
        .arg(
            Arg::with_name(MAPPING_VERSION_NAME)
                .long(MAPPING_VERSION_NAME)
                .help("name of the noop mapping that will be inserted")
                .takes_value(true)
                .required(true),
        );

    let sync_commit_and_ancestors = SubCommand::with_name(SYNC_COMMIT_AND_ANCESTORS)
        .about(
            "
            Command that syncs a commit and all of its unsynced ancestors from source repo \
            to target repo. This is similar to SCS commit_lookup_xrepo() method except that it \
            doesn't do all the safety checks that commit_lookup_xrepo(). In particular, it allows \
            to sync public small repo commits.
        ",
        )
        .arg(
            Arg::with_name(COMMIT_HASH)
                .long(COMMIT_HASH)
                .help("commit (and its ancestors) to sync")
                .takes_value(true)
                .required(true),
        );

    let diff_mapping_versions = SubCommand::with_name(DIFF_MAPPING_VERSIONS)
        .about("Show difference between two mapping versions.")
        .arg(
            Arg::with_name(MAPPING_VERSION_NAME)
                .help("list of mapping versions")
                .takes_value(true)
                .multiple(true)
                .required(true),
        );

    let delete_no_longer_bound_files_from_large_repo = SubCommand::with_name(DELETE_NO_LONGER_BOUND_FILES_FROM_LARGE_REPO)
        .about("
        Right after small and large are bound usually a majority of small repo files map to a single folder \
        in large repo (let's call it DIR). Later these files from small repo might be bound to a another files in large repo \
        however files in DIR might still exist in large repo. \
        This command allows us to delete these files from DIR. It does so by finding all files in DIR and its subfolders \
        that do not remap to a small repo and then deleting them. \
        Note: if there are files in DIR that were never part of a bind, they will be deleted.
        ")
        .arg(
            Arg::with_name(COMMIT_HASH)
                .long(COMMIT_HASH)
                .required(true)
                .takes_value(true)
                .help("hg/bonsai changeset id or bookmark"),
        )
        .arg(
            Arg::with_name(PATH_PREFIX)
                .long(PATH_PREFIX)
                .required(true)
                .takes_value(true)
                .help("path prefix where to search for files to delete from"),
        );

    args::MononokeAppBuilder::new("megarepo preparation tool")
        .with_advanced_args_hidden()
        .with_source_and_target_repos()
        .build()
        .subcommand(add_resulting_commit_args(move_subcommand))
        .subcommand(add_resulting_commit_args(merge_subcommand))
        .subcommand(sync_diamond_subcommand)
        .subcommand(add_light_resulting_commit_args(pre_merge_delete_subcommand))
        .subcommand(history_fixup_delete_subcommand)
        .subcommand(add_light_resulting_commit_args(bonsai_merge_subcommand))
        .subcommand(add_light_resulting_commit_args(gradual_merge_subcommand))
        .subcommand(gradual_merge_progress_subcommand)
        .subcommand(gradual_delete_subcommand)
        .subcommand(manual_commit_sync_subcommand)
        .subcommand(add_light_resulting_commit_args(
            catchup_delete_head_subcommand,
        ))
        .subcommand(catchup_validate_subcommand)
        .subcommand(mark_not_synced_candidate)
        .subcommand(check_push_redirection_prereqs_subcommand)
        .subcommand(run_mover_subcommand)
        .subcommand(backfill_noop_mapping)
        .subcommand(sync_commit_and_ancestors)
        .subcommand(diff_mapping_versions)
        .subcommand(add_light_resulting_commit_args(
            delete_no_longer_bound_files_from_large_repo,
        ))
}


/*
 * 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::collections::VecDeque;

use anyhow::Error;
use blobrepo::BlobRepo;
use blobstore::Loadable;
use bookmarks::BookmarkKey;
use changeset_fetcher::ChangesetFetcherRef;
use cmdlib::helpers;
use commit_graph::CommitGraphRef;
use context::CoreContext;
use futures::StreamExt;
use maplit::hashset;
use megarepolib::common::create_and_save_bonsai;
use megarepolib::common::ChangesetArgs;
use megarepolib::common::ChangesetArgsFactory;
use megarepolib::common::StackPosition;
use mercurial_derivation::DeriveHgChangeset;
use metaconfig_types::PushrebaseFlags;
use mononoke_api_types::InnerRepo;
use mononoke_types::ChangesetId;
use pushrebase::do_pushrebase_bonsai;
use repo_blobstore::RepoBlobstoreRef;
use slog::info;

pub struct GradualMergeParams {
    pub pre_deletion_commit: ChangesetId,
    pub last_deletion_commit: ChangesetId,
    pub bookmark_to_merge_into: BookmarkKey,
    pub merge_changeset_args_factory: Box<dyn ChangesetArgsFactory>,
    pub limit: Option<usize>,
    pub dry_run: bool,
}

/// Get total number of commits to merge and list
/// of commits that haven't been merged yet
async fn get_unmerged_commits_with_total_count(
    ctx: &CoreContext,
    repo: &InnerRepo,
    pre_deletion_commit: &ChangesetId,
    last_deletion_commit: &ChangesetId,
    bookmark_to_merge_into: &BookmarkKey,
) -> Result<(usize, Vec<(ChangesetId, StackPosition)>), Error> {
    let commits_to_merge = find_all_commits_to_merge(
        ctx,
        &repo.blob_repo,
        *pre_deletion_commit,
        *last_deletion_commit,
    )
    .await?;

    info!(
        ctx.logger(),
        "{} total commits to merge",
        commits_to_merge.len()
    );

    let commits_to_merge = commits_to_merge
        .into_iter()
        .enumerate()
        .map(|(idx, cs_id)| (cs_id, StackPosition(idx)))
        .collect::<Vec<_>>();

    let total_count = commits_to_merge.len();

    let unmerged_commits =
        find_unmerged_commits(ctx, repo, commits_to_merge, bookmark_to_merge_into).await?;

    Ok((total_count, unmerged_commits))
}

/// Get how many merges has been done and how many merges are there in total
pub async fn gradual_merge_progress(
    ctx: &CoreContext,
    repo: &InnerRepo,
    pre_deletion_commit: &ChangesetId,
    last_deletion_commit: &ChangesetId,
    bookmark_to_merge_into: &BookmarkKey,
) -> Result<(usize, usize), Error> {
    let (to_merge_count, unmerged_commits) = get_unmerged_commits_with_total_count(
        ctx,
        repo,
        pre_deletion_commit,
        last_deletion_commit,
        bookmark_to_merge_into,
    )
    .await?;
    Ok((to_merge_count - unmerged_commits.len(), to_merge_count))
}

// This function implements a strategy to merge a large repository into another
// while avoiding sudden increase in the working copy size.
// Normally this function should be called after a list of deletion has been created
// (see pre_merge_delete functions). Each of these deletion commits decreases the
// size of the working copy, and so each of the merge adds just part of the working copy.
// See https://fburl.com/ujc6sv7x for more details.
//
//
//   M_B
//   |
//  ...
//   |
//   M_A
//   |
//   O <- head where we want to merge
//   |
//   |      A - deletion commit
//   O      |
//   |      B - deletion commit
//   O      |
//          O <- commit that we want to merge
//         ...
//
// M_A, M_B are merge commits that merge deletion commits. Note that the top deletion commit A
// is merged first, and deletion commit B is merged next.
pub async fn gradual_merge(
    ctx: &CoreContext,
    repo: &InnerRepo,
    params: &GradualMergeParams,
    pushrebase_flags: &PushrebaseFlags,
) -> Result<HashMap<ChangesetId, ChangesetId>, Error> {
    let GradualMergeParams {
        pre_deletion_commit,
        last_deletion_commit,
        bookmark_to_merge_into,
        merge_changeset_args_factory,
        limit,
        dry_run,
    } = params;

    let (_, unmerged_commits) = get_unmerged_commits_with_total_count(
        ctx,
        repo,
        pre_deletion_commit,
        last_deletion_commit,
        bookmark_to_merge_into,
    )
    .await?;

    let unmerged_commits = if let Some(limit) = limit {
        unmerged_commits.into_iter().take(*limit).collect()
    } else {
        unmerged_commits
    };
    info!(ctx.logger(), "merging {} commits", unmerged_commits.len());

    let mut res = HashMap::new();
    if !dry_run {
        for (cs_id, stack_pos) in unmerged_commits {
            let merge_changeset_args = merge_changeset_args_factory(stack_pos);
            let res_cs_id = push_merge_commit(
                ctx,
                &repo.blob_repo,
                cs_id,
                bookmark_to_merge_into,
                merge_changeset_args,
                pushrebase_flags,
            )
            .await?;

            res.insert(cs_id, res_cs_id);
        }
    } else {
        for (cs_id, stack_pos) in unmerged_commits {
            info!(
                ctx.logger(),
                "merging commits {}, with stack position {:?}", cs_id, stack_pos
            );
        }
    }

    Ok(res)
}

// Finds all commits that needs to be merged, regardless of whether they've been merged
// already or not.
async fn find_all_commits_to_merge(
    ctx: &CoreContext,
    repo: &BlobRepo,
    pre_deletion_commit: ChangesetId,
    last_deletion_commit: ChangesetId,
) -> Result<Vec<ChangesetId>, Error> {
    info!(ctx.logger(), "Finding all commits to merge...");
    let commits_to_merge = repo
        .commit_graph()
        .range_stream(ctx, pre_deletion_commit, last_deletion_commit)
        .await?
        .collect::<Vec<_>>()
        .await
        .into_iter()
        .rev()
        .collect::<Vec<_>>();

    Ok(commits_to_merge)
}

// Out of all commits that need to be merged find commits that haven't been merged yet
async fn find_unmerged_commits(
    ctx: &CoreContext,
    repo: &InnerRepo,
    mut commits_to_merge: Vec<(ChangesetId, StackPosition)>,
    bookmark_to_merge_into: &BookmarkKey,
) -> Result<Vec<(ChangesetId, StackPosition)>, Error> {
    info!(
        ctx.logger(),
        "Finding commits that haven't been merged yet..."
    );
    let first = if let Some(first) = commits_to_merge.first() {
        first
    } else {
        // All commits has been merged already - nothing to do, just exit
        return Ok(vec![]);
    };

    let bookmark_value =
        helpers::csid_resolve(ctx, &repo.blob_repo, bookmark_to_merge_into).await?;

    // Let's check if any commits has been merged already - to do that it's enough
    // to check if the first commit has been merged or not i.e. check if this commit
    // is ancestor of bookmark_to_merge_into or not.
    let has_merged_any = repo
        .commit_graph()
        .is_ancestor(ctx, first.0, bookmark_value)
        .await?;
    if !has_merged_any {
        return Ok(commits_to_merge);
    }

    // At this point we know that we've already merged at least a single commit.
    // Let's find the last commit that has been merged already.
    // To do that we do a bfs traversal starting from bookmark_to_merge_into, and
    // we stop as soon as we've found a commit from commits_to_merge list - because
    // we are merging starting from the top deletion commit, the latest merged commit
    // will be the closest to bookmark_to_merge_into
    //
    //   O <- bookmark_to_merge_into
    //   |
    //  ...
    //   M_B  <- we'll find this commit before M_A,  it's parent is commit B.
    //   |
    //  ...
    //   |
    //   M_A
    //   |
    //   O <- head where we want to merge
    //   |
    //   |      A - deletion commit, it was merged first in M_A
    //   O      |
    //   |      B - deletion commit, it was merged second in M_B
    //   O      |
    //          C <- it hasn't been merged yet
    //         ...
    //
    let mut commits_to_idx = HashMap::new();
    for (cs_id, stack_pos) in commits_to_merge.iter() {
        commits_to_idx.insert(*cs_id, *stack_pos);
    }

    let mut queue = VecDeque::new();
    queue.push_back(bookmark_value.clone());
    let mut visited = HashSet::new();
    visited.insert(bookmark_value);
    while let Some(cs_id) = queue.pop_back() {
        if let Some(found_idx) = commits_to_idx.get(&cs_id) {
            return Ok(commits_to_merge.split_off(found_idx.0 + 1));
        }

        let parents = repo
            .blob_repo
            .changeset_fetcher()
            .get_parents(ctx, cs_id)
            .await?;
        for p in parents {
            if visited.insert(p) {
                queue.push_back(p);
            }
        }
    }

    Ok(commits_to_merge)
}

// Pushrebase a single merge commit
async fn push_merge_commit(
    ctx: &CoreContext,
    repo: &BlobRepo,
    cs_id_to_merge: ChangesetId,
    bookmark_to_merge_into: &BookmarkKey,
    merge_changeset_args: ChangesetArgs,
    pushrebase_flags: &PushrebaseFlags,
) -> Result<ChangesetId, Error> {
    info!(ctx.logger(), "Preparing to merge {}", cs_id_to_merge);
    let bookmark_value = helpers::csid_resolve(ctx, repo, bookmark_to_merge_into).await?;

    let merge_cs_id = create_and_save_bonsai(
        ctx,
        repo,
        vec![bookmark_value, cs_id_to_merge],
        Default::default(),
        merge_changeset_args,
    )
    .await?;

    info!(ctx.logger(), "Created merge changeset {}", merge_cs_id);

    // Generating hg changeset from bonsai changeset will give us a validation
    // that this merge commit is correct
    let merge_hg_cs_id = repo.derive_hg_changeset(ctx, merge_cs_id).await?;

    info!(ctx.logger(), "Generated hg changeset {}", merge_hg_cs_id);
    info!(ctx.logger(), "Now running pushrebase...");

    let merge_cs = merge_cs_id.load(ctx, repo.repo_blobstore()).await?;
    let pushrebase_res = do_pushrebase_bonsai(
        ctx,
        repo,
        pushrebase_flags,
        bookmark_to_merge_into,
        &hashset![merge_cs],
        &[],
    )
    .await?;

    info!(ctx.logger(), "Pushrebased to {}", pushrebase_res.head);
    Ok(pushrebase_res.head)
}

#[cfg(test)]
mod test {
    use fbinit::FacebookInit;
    use maplit::hashmap;
    use mononoke_types::DateTime;
    use mononoke_types::MPath;
    use tests_utils::bookmark;
    use tests_utils::drawdag::create_from_dag;
    use tests_utils::list_working_copy_utf8;
    use tests_utils::CreateCommitContext;

    use super::*;

    #[fbinit::test]
    async fn test_find_all_commits_to_merge(fb: FacebookInit) -> Result<(), Error> {
        let ctx = CoreContext::test_mock(fb);
        let (repo, pre_deletion_commit, deletion_commits) = create_repo(&ctx).await?;
        let commits_to_merge = find_all_commits_to_merge(
            &ctx,
            &repo.blob_repo,
            pre_deletion_commit,
            *deletion_commits.last().unwrap(),
        )
        .await?;
        let mut expected = vec![pre_deletion_commit];
        expected.extend(deletion_commits);
        expected.reverse();
        assert_eq!(commits_to_merge, expected);
        Ok(())
    }

    #[fbinit::test]
    async fn test_find_unmerged_commits(fb: FacebookInit) -> Result<(), Error> {
        let ctx = CoreContext::test_mock(fb);
        let (repo, pre_deletion_commit, deletion_commits) = create_repo(&ctx).await?;
        let commits_to_merge = find_all_commits_to_merge(
            &ctx,
            &repo.blob_repo,
            pre_deletion_commit,
            *deletion_commits.last().unwrap(),
        )
        .await?;

        let commits_to_merge = commits_to_merge
            .into_iter()
            .enumerate()
            .map(|(idx, cs_id)| (cs_id, StackPosition(idx)))
            .collect::<Vec<_>>();
        let head = BookmarkKey::new("head")?;
        let unmerged_commits =
            find_unmerged_commits(&ctx, &repo, commits_to_merge.clone(), &head).await?;

        assert_eq!(commits_to_merge, unmerged_commits);

        // Now merge a single commit into "head"
        let head_value = helpers::csid_resolve(&ctx, &repo.blob_repo, head.clone()).await?;

        let merge = CreateCommitContext::new(
            &ctx,
            &repo.blob_repo,
            vec![head_value, unmerged_commits.first().unwrap().0],
        )
        .commit()
        .await?;
        bookmark(&ctx, &repo.blob_repo, "head")
            .set_to(merge)
            .await?;

        let unmerged_commits =
            find_unmerged_commits(&ctx, &repo, commits_to_merge.clone(), &head).await?;
        let mut expected = commits_to_merge.clone();
        expected.remove(0);
        assert_eq!(expected, unmerged_commits);

        // Merge next commit into head
        let head_value = helpers::csid_resolve(&ctx, &repo.blob_repo, head.clone()).await?;

        let merge = CreateCommitContext::new(
            &ctx,
            &repo.blob_repo,
            vec![head_value, unmerged_commits.first().unwrap().0],
        )
        .commit()
        .await?;
        bookmark(&ctx, &repo.blob_repo, "head")
            .set_to(merge)
            .await?;

        let unmerged_commits =
            find_unmerged_commits(&ctx, &repo, commits_to_merge.clone(), &head).await?;
        let mut expected = commits_to_merge.clone();
        expected.remove(0);
        expected.remove(0);
        assert_eq!(expected, unmerged_commits);

        Ok(())
    }

    #[fbinit::test]
    async fn test_gradual_merge(fb: FacebookInit) -> Result<(), Error> {
        let ctx = CoreContext::test_mock(fb);
        let (repo, pre_deletion_commit, deletion_commits) = create_repo(&ctx).await?;

        let args_factory = get_default_merge_args_factory();
        let mut params = GradualMergeParams {
            pre_deletion_commit,
            last_deletion_commit: *deletion_commits.last().unwrap(),
            bookmark_to_merge_into: BookmarkKey::new("head")?,
            merge_changeset_args_factory: args_factory,
            limit: None,
            dry_run: false,
        };

        let pushrebase_flags = PushrebaseFlags {
            rewritedates: true,
            forbid_p2_root_rebases: true,
            casefolding_check: true,
            recursion_limit: None,
            ..Default::default()
        };

        // Test dry-run mode
        params.dry_run = true;
        let merged = gradual_merge(&ctx, &repo, &params, &pushrebase_flags).await?;
        assert!(merged.is_empty());

        params.dry_run = false;
        let merged = gradual_merge(&ctx, &repo, &params, &pushrebase_flags).await?;
        verify_gradual_merges(
            &ctx,
            &repo.blob_repo,
            merged,
            pre_deletion_commit,
            &deletion_commits,
        )
        .await?;

        Ok(())
    }

    #[fbinit::test]
    async fn test_gradual_merge_with_limit(fb: FacebookInit) -> Result<(), Error> {
        let ctx = CoreContext::test_mock(fb);
        let (repo, pre_deletion_commit, deletion_commits) = create_repo(&ctx).await?;

        let args_factory = get_default_merge_args_factory();
        let params = GradualMergeParams {
            pre_deletion_commit,
            last_deletion_commit: *deletion_commits.last().unwrap(),
            bookmark_to_merge_into: BookmarkKey::new("head")?,
            merge_changeset_args_factory: args_factory,
            limit: Some(1),
            dry_run: false,
        };

        let pushrebase_flags = PushrebaseFlags {
            rewritedates: true,
            forbid_p2_root_rebases: true,
            casefolding_check: true,
            recursion_limit: None,
            ..Default::default()
        };

        let mut result = HashMap::new();
        for _ in 0..3 {
            let merged = gradual_merge(&ctx, &repo, &params, &pushrebase_flags).await?;
            assert_eq!(merged.len(), 1);
            result.extend(merged)
        }
        verify_gradual_merges(
            &ctx,
            &repo.blob_repo,
            result,
            pre_deletion_commit,
            &deletion_commits,
        )
        .await?;

        Ok(())
    }

    #[fbinit::test]
    async fn test_stack_position(fb: FacebookInit) -> Result<(), Error> {
        let ctx = CoreContext::test_mock(fb);
        let (repo, pre_deletion_commit, deletion_commits) = create_repo(&ctx).await?;
        let args_factory = Box::new(|stack_pos: StackPosition| ChangesetArgs {
            author: "author".to_string(),
            message: format!("{}", stack_pos.0),
            datetime: DateTime::now(),
            bookmark: None,
            mark_public: false,
        });

        let params = GradualMergeParams {
            pre_deletion_commit,
            last_deletion_commit: *deletion_commits.last().unwrap(),
            bookmark_to_merge_into: BookmarkKey::new("head")?,
            merge_changeset_args_factory: args_factory,
            limit: Some(1),
            dry_run: false,
        };

        let pushrebase_flags = PushrebaseFlags {
            rewritedates: true,
            forbid_p2_root_rebases: true,
            casefolding_check: true,
            recursion_limit: None,
            ..Default::default()
        };

        let mut result = HashMap::new();
        for i in 0..3 {
            let merged = gradual_merge(&ctx, &repo, &params, &pushrebase_flags).await?;
            assert_eq!(merged.len(), 1);
            let pushrebased_cs_id = merged.values().next().unwrap();
            let bcs_id = pushrebased_cs_id
                .load(&ctx, repo.blob_repo.repo_blobstore())
                .await?;
            assert_eq!(bcs_id.message(), format!("{}", i));
            result.extend(merged)
        }
        verify_gradual_merges(
            &ctx,
            &repo.blob_repo,
            result,
            pre_deletion_commit,
            &deletion_commits,
        )
        .await?;

        Ok(())
    }

    async fn create_repo(
        ctx: &CoreContext,
    ) -> Result<(InnerRepo, ChangesetId, Vec<ChangesetId>), Error> {
        let repo: InnerRepo = test_repo_factory::build_empty(ctx.fb).await?;

        let dag = create_from_dag(
            ctx,
            &repo.blob_repo,
            r##"
               A-B-C

               D-E-F
             "##,
        )
        .await?;
        let pre_deletion_commit = *dag.get("F").unwrap();

        // Create deletion commits
        let first_deletion_commit =
            CreateCommitContext::new(ctx, &repo.blob_repo, vec![pre_deletion_commit])
                .delete_file("F")
                .commit()
                .await?;

        let second_deletion_commit =
            CreateCommitContext::new(ctx, &repo.blob_repo, vec![first_deletion_commit])
                .delete_file("E")
                .commit()
                .await?;

        bookmark(ctx, &repo.blob_repo, "head")
            .set_to(*dag.get("C").unwrap())
            .await?;

        Ok((
            repo,
            pre_deletion_commit,
            vec![first_deletion_commit, second_deletion_commit],
        ))
    }

    async fn verify_gradual_merges(
        ctx: &CoreContext,
        repo: &BlobRepo,
        gradual_merge_result: HashMap<ChangesetId, ChangesetId>,
        pre_deletion_commit: ChangesetId,
        deletion_commits: &[ChangesetId],
    ) -> Result<(), Error> {
        assert_eq!(gradual_merge_result.len(), 3);
        let working_copy = list_working_copy_utf8(
            ctx,
            &repo,
            *gradual_merge_result.get(&pre_deletion_commit).unwrap(),
        )
        .await?;
        assert_eq!(
            hashmap! {
                MPath::new("A")? => "A".to_string(),
                MPath::new("B")? => "B".to_string(),
                MPath::new("C")? => "C".to_string(),
                MPath::new("D")? => "D".to_string(),
                MPath::new("E")? => "E".to_string(),
                MPath::new("F")? => "F".to_string(),
            },
            working_copy
        );

        let working_copy = list_working_copy_utf8(
            ctx,
            &repo,
            *gradual_merge_result.get(&deletion_commits[0]).unwrap(),
        )
        .await?;
        assert_eq!(
            hashmap! {
                MPath::new("A")? => "A".to_string(),
                MPath::new("B")? => "B".to_string(),
                MPath::new("C")? => "C".to_string(),
                MPath::new("D")? => "D".to_string(),
                MPath::new("E")? => "E".to_string(),
            },
            working_copy
        );

        let working_copy = list_working_copy_utf8(
            ctx,
            &repo,
            *gradual_merge_result.get(&deletion_commits[1]).unwrap(),
        )
        .await?;
        assert_eq!(
            hashmap! {
                MPath::new("A")? => "A".to_string(),
                MPath::new("B")? => "B".to_string(),
                MPath::new("C")? => "C".to_string(),
                MPath::new("D")? => "D".to_string(),
            },
            working_copy
        );

        for merge_cs_id in gradual_merge_result.values() {
            let hg_cs_id = repo.derive_hg_changeset(ctx, *merge_cs_id).await?;
            let hg_cs = hg_cs_id.load(ctx, repo.repo_blobstore()).await?;
            assert!(hg_cs.files().is_empty());
        }
        Ok(())
    }

    fn get_default_merge_args_factory() -> Box<dyn ChangesetArgsFactory> {
        Box::new(|_| ChangesetArgs {
            author: "author".to_string(),
            message: "merge".to_string(),
            datetime: DateTime::now(),
            bookmark: None,
            mark_public: false,
        })
    }
}