diff --git a/src/jamidht/conversationrepository.cpp b/src/jamidht/conversationrepository.cpp index 089c15107e9149d600948e33543d2fe9bc8d466a..8f6b94408146c9403e3611e4e517459ab8b8169c 100644 --- a/src/jamidht/conversationrepository.cpp +++ b/src/jamidht/conversationrepository.cpp @@ -26,7 +26,6 @@ #include <opendht/rng.h> using random_device = dht::crypto::random_device; - #include <ctime> #include <fstream> @@ -54,6 +53,10 @@ public: } ~Impl() = default; + GitSignature signature(); + bool mergeFastforward(const git_oid* target_oid, int is_unborn); + bool createMergeCommit(git_index* index, const std::string& wanted_ref); + std::weak_ptr<JamiAccount> account_; const std::string id_; GitRepository repository_ {nullptr, git_repository_free}; @@ -70,7 +73,10 @@ GitRepository create_empty_repository(const std::string& path) { git_repository* repo = nullptr; - if (git_repository_init(&repo, path.c_str(), false /* we want a non-bare repo to work on it */) + git_repository_init_options opts = GIT_REPOSITORY_INIT_OPTIONS_INIT; + opts.flags |= GIT_REPOSITORY_INIT_MKPATH; + opts.initial_head = "main"; + if (git_repository_init_ext(&repo, path.c_str(), &opts) < 0) { JAMI_ERR("Couldn't create a git repository in %s", path.c_str()); } @@ -246,11 +252,11 @@ initial_commit(GitRepository& repo, const std::shared_ptr<JamiAccount>& account) return {}; } - // Move commit to master branch + // Move commit to main branch git_commit* commit = nullptr; if (git_commit_lookup(&commit, repo.get(), &commit_id) == 0) { git_reference* ref = nullptr; - git_branch_create(&ref, repo.get(), "master", commit, true); + git_branch_create(&ref, repo.get(), "main", commit, true); git_commit_free(commit); git_reference_free(ref); } @@ -261,6 +267,219 @@ initial_commit(GitRepository& repo, const std::shared_ptr<JamiAccount>& account) return {}; } +////////////////////////////////// + +GitSignature +ConversationRepository::Impl::signature() +{ + auto account = account_.lock(); + if (!account) + return {nullptr, git_signature_free}; + auto deviceId = std::string(account->currentDeviceId()); + auto name = account->getDisplayName(); + if (name.empty()) + name = deviceId; + + git_signature* sig_ptr = nullptr; + // Sign commit's buffer + if (git_signature_new(&sig_ptr, name.c_str(), deviceId.c_str(), std::time(nullptr), 0) < 0) { + JAMI_ERR("Unable to create a commit signature."); + return {nullptr, git_signature_free}; + } + GitSignature sig {sig_ptr, git_signature_free}; + return std::move(sig); +} + +bool +ConversationRepository::Impl::createMergeCommit(git_index* index, const std::string& wanted_ref) +{ + // The merge will occur between current HEAD and wanted_ref + git_reference* head_ref_ptr = nullptr; + if (git_repository_head(&head_ref_ptr, repository_.get()) < 0) { + JAMI_ERR("Could not get HEAD reference"); + return false; + } + GitReference head_ref {head_ref_ptr, git_reference_free}; + + // Maybe that's a ref, so DWIM it + git_reference* merge_ref_ptr = nullptr; + git_reference_dwim(&merge_ref_ptr, repository_.get(), wanted_ref.c_str()); + GitReference merge_ref {merge_ref_ptr, git_reference_free}; + + GitSignature sig {signature()}; + + // Prepare a standard merge commit message + const char* msg_target = nullptr; + if (merge_ref) { + git_branch_name(&msg_target, merge_ref.get()); + } else { + msg_target = wanted_ref.c_str(); + } + + std::stringstream stream; + stream << "Merge " << (merge_ref ? "branch" : "commit") << " '" << msg_target << "'"; + + // Setup our parent commits + GitCommit parents[2] {{nullptr, git_commit_free}, {nullptr, git_commit_free}}; + git_commit* parent = nullptr; + if (git_reference_peel((git_object**) &parent, head_ref.get(), GIT_OBJ_COMMIT) < 0) { + JAMI_ERR("Could not peel HEAD reference"); + return false; + } + parents[0] = {parent, git_commit_free}; + git_oid commit_id; + if (git_oid_fromstr(&commit_id, wanted_ref.c_str()) < 0) { + return false; + } + git_annotated_commit* annotated_ptr = nullptr; + if (git_annotated_commit_lookup(&annotated_ptr, repository_.get(), &commit_id) < 0) { + JAMI_ERR("Couldn't lookup commit %s", wanted_ref.c_str()); + return false; + } + GitAnnotatedCommit annotated {annotated_ptr, git_annotated_commit_free}; + if (git_commit_lookup(&parent, repository_.get(), git_annotated_commit_id(annotated.get())) + < 0) { + JAMI_ERR("Couldn't lookup commit %s", wanted_ref.c_str()); + return false; + } + parents[1] = {parent, git_commit_free}; + + // Prepare our commit tree + git_oid tree_oid; + git_tree* tree = nullptr; + if (git_index_write_tree(&tree_oid, index) < 0) { + JAMI_ERR("Couldn't write index"); + return false; + } + if (git_tree_lookup(&tree, repository_.get(), &tree_oid) < 0) { + JAMI_ERR("Couldn't lookup tree"); + return false; + } + + // Commit + git_buf to_sign = {}; + const git_commit* parents_ptr[2] {parents[0].get(), parents[1].get()}; + if (git_commit_create_buffer(&to_sign, + repository_.get(), + sig.get(), + sig.get(), + nullptr, + stream.str().c_str(), + tree, + 2, + &parents_ptr[0]) + < 0) { + JAMI_ERR("Could not create commit buffer"); + return false; + } + + auto account = account_.lock(); + if (!account) + false; + // git commit -S + auto to_sign_vec = std::vector<uint8_t>(to_sign.ptr, to_sign.ptr + to_sign.size); + auto signed_buf = account->identity().first->sign(to_sign_vec); + std::string signed_str = base64::encode(signed_buf); + git_oid commit_oid; + if (git_commit_create_with_signature(&commit_oid, + repository_.get(), + to_sign.ptr, + signed_str.c_str(), + "signature") + < 0) { + JAMI_ERR("Could not sign commit"); + return false; + } + + auto commit_str = git_oid_tostr_s(&commit_oid); + if (commit_str) { + JAMI_INFO("New merge commit added with id: %s", commit_str); + // Move commit to main branch + git_reference* ref_ptr = nullptr; + if (git_reference_create(&ref_ptr, + repository_.get(), + "refs/heads/main", + &commit_oid, + true, + nullptr) + < 0) { + JAMI_WARN("Could not move commit to main"); + } + git_reference_free(ref_ptr); + } + + // We're done merging, cleanup the repository state + git_repository_state_cleanup(repository_.get()); + + return true; +} + +bool +ConversationRepository::Impl::mergeFastforward(const git_oid* target_oid, int is_unborn) +{ + // Initialize target + git_reference* target_ref_ptr = nullptr; + if (is_unborn) { + git_reference* head_ref_ptr = nullptr; + // HEAD reference is unborn, lookup manually so we don't try to resolve it + if (git_reference_lookup(&head_ref_ptr, repository_.get(), "HEAD") < 0) { + JAMI_ERR("failed to lookup HEAD ref"); + return false; + } + GitReference head_ref {head_ref_ptr, git_reference_free}; + + // Grab the reference HEAD should be pointing to + const auto* symbolic_ref = git_reference_symbolic_target(head_ref.get()); + + // Create our main reference on the target OID + if (git_reference_create(&target_ref_ptr, + repository_.get(), + symbolic_ref, + target_oid, + 0, + nullptr) + < 0) { + JAMI_ERR("failed to create main reference"); + return false; + } + + } else if (git_repository_head(&target_ref_ptr, repository_.get()) < 0) { + // HEAD exists, just lookup and resolve + JAMI_ERR("failed to get HEAD reference"); + return false; + } + GitReference target_ref {target_ref_ptr, git_reference_free}; + + // Lookup the target object + git_object* target_ptr = nullptr; + if (git_object_lookup(&target_ptr, repository_.get(), target_oid, GIT_OBJ_COMMIT) != 0) { + JAMI_ERR("failed to lookup OID %s", git_oid_tostr_s(target_oid)); + return false; + } + GitObject target {target_ptr, git_object_free}; + + // Checkout the result so the workdir is in the expected state + git_checkout_options ff_checkout_options; + git_checkout_init_options(&ff_checkout_options, GIT_CHECKOUT_OPTIONS_VERSION); + ff_checkout_options.checkout_strategy = GIT_CHECKOUT_SAFE; + if (git_checkout_tree(repository_.get(), target.get(), &ff_checkout_options) != 0) { + JAMI_ERR("failed to checkout HEAD reference"); + return false; + } + + // Move the target reference to the target OID + git_reference* new_target_ref; + if (git_reference_set_target(&new_target_ref, target_ref.get(), target_oid, nullptr) < 0) { + JAMI_ERR("failed to move HEAD reference"); + return false; + } + git_reference_free(new_target_ref); + + return 0; +} + +////////////////////////////////// + std::unique_ptr<ConversationRepository> ConversationRepository::createConversation(const std::weak_ptr<JamiAccount>& account) { @@ -612,4 +831,96 @@ ConversationRepository::log(const std::string& last, unsigned n) return commits; } +bool +ConversationRepository::merge(const std::string& merge_id) +{ + // First, the repository must be in a clean state + int state = git_repository_state(pimpl_->repository_.get()); + if (state != GIT_REPOSITORY_STATE_NONE) { + JAMI_ERR("Merge operation aborted: repository is in unexpected state %d", state); + return false; + } + // Checkout main (to do a `git_merge branch`) + if (git_repository_set_head(pimpl_->repository_.get(), "refs/heads/main") < 0) { + JAMI_ERR("Merge operation aborted: couldn't checkout main branch"); + return false; + } + + // Then check that merge_id exists + git_oid commit_id; + if (git_oid_fromstr(&commit_id, merge_id.c_str()) < 0) { + JAMI_ERR("Merge operation aborted: couldn't lookup commit %s", merge_id.c_str()); + return false; + } + git_annotated_commit* annotated_ptr = nullptr; + if (git_annotated_commit_lookup(&annotated_ptr, pimpl_->repository_.get(), &commit_id) < 0) { + JAMI_ERR("Merge operation aborted: couldn't lookup commit %s", merge_id.c_str()); + return false; + } + GitAnnotatedCommit annotated {annotated_ptr, git_annotated_commit_free}; + + // Now, we can analyze which type of merge do we need + git_merge_analysis_t analysis; + git_merge_preference_t preference; + const git_annotated_commit* const_annotated = annotated.get(); + if (git_merge_analysis(&analysis, &preference, pimpl_->repository_.get(), &const_annotated, 1) + < 0) { + JAMI_ERR("Merge operation aborted: repository analysis failed"); + return false; + } + + if (analysis & GIT_MERGE_ANALYSIS_UP_TO_DATE) { + JAMI_INFO("Already up-to-date"); + return true; + } else if (analysis & GIT_MERGE_ANALYSIS_UNBORN + || (analysis & GIT_MERGE_ANALYSIS_FASTFORWARD + && !(preference & GIT_MERGE_PREFERENCE_NO_FASTFORWARD))) { + if (analysis & GIT_MERGE_ANALYSIS_UNBORN) + JAMI_INFO("Merge analysis result: Unborn"); + else + JAMI_INFO("Merge analysis result: Fast-forward"); + const auto* target_oid = git_annotated_commit_id(annotated.get()); + + if (pimpl_->mergeFastforward(target_oid, (analysis & GIT_MERGE_ANALYSIS_UNBORN)) < 0) { + const git_error* err = giterr_last(); + if (err) + JAMI_ERR("Fast forward merge failed: %s", err->message); + return false; + } + return true; + } else if (analysis & GIT_MERGE_ANALYSIS_NORMAL) { + git_merge_options merge_opts = GIT_MERGE_OPTIONS_INIT; + merge_opts.file_flags = GIT_MERGE_FILE_STYLE_DIFF3; + git_checkout_options checkout_opts = GIT_CHECKOUT_OPTIONS_INIT; + checkout_opts.checkout_strategy = GIT_CHECKOUT_FORCE | GIT_CHECKOUT_ALLOW_CONFLICTS; + + if (preference & GIT_MERGE_PREFERENCE_FASTFORWARD_ONLY) { + JAMI_ERR("Fast-forward is preferred, but only a merge is possible"); + return false; + } + + if (git_merge(pimpl_->repository_.get(), &const_annotated, 1, &merge_opts, &checkout_opts) + < 0) { + const git_error* err = giterr_last(); + if (err) + JAMI_ERR("Git merge failed: %s", err->message); + return false; + } + } + + git_index* index_ptr = nullptr; + if (git_repository_index(&index_ptr, pimpl_->repository_.get()) < 0) { + JAMI_ERR("Merge operation aborted: could not open repository index"); + return false; + } + GitIndex index {index_ptr, git_index_free}; + if (git_index_has_conflicts(index.get())) { + JAMI_WARN("Merge operation aborted: the merge operation resulted in some conflicts"); + return false; + } + auto result = pimpl_->createMergeCommit(index.get(), merge_id); + JAMI_INFO("Merge done between %s and main", merge_id.c_str()); + return result; +} + } // namespace jami diff --git a/src/jamidht/conversationrepository.h b/src/jamidht/conversationrepository.h index c1bf213e6f3efd8e3d6c078fa5f92ccc7c6609f9..b2d85c91860bfda854e47a3d465051a95304eec6 100644 --- a/src/jamidht/conversationrepository.h +++ b/src/jamidht/conversationrepository.h @@ -21,6 +21,7 @@ #include <memory> #include <string> #include <vector> +#include <git2.h> #include "def.h" @@ -41,7 +42,6 @@ using GitDiffStats = std::unique_ptr<git_diff_stats, decltype(&git_diff_stats_fr using GitIndexConflictIterator = std::unique_ptr<git_index_conflict_iterator, decltype(&git_index_conflict_iterator_free)>; - namespace jami { class JamiAccount; @@ -111,10 +111,10 @@ public: /** * Retrieve remote head. Can be useful after a fetch operation * @param remoteDeviceId The remote name - * @param branch Remote branch to check (default: master) + * @param branch Remote branch to check (default: main) * @return the commit id pointed */ - std::string remoteHead(const std::string& remoteDeviceId, const std::string& branch = "master"); + std::string remoteHead(const std::string& remoteDeviceId, const std::string& branch = "main"); /** * Return the conversation id @@ -136,6 +136,13 @@ public: */ std::vector<ConversationCommit> log(const std::string& last = "", unsigned n = 0); + /** + * Merge another branch into the main branch + * @param merge_id The reference to merge + * @return if the merge was successful + */ + bool merge(const std::string& merge_id); + private: ConversationRepository() = delete; class Impl; diff --git a/test/unitTest/conversationRepository/conversationRepository.cpp b/test/unitTest/conversationRepository/conversationRepository.cpp index e456714cf48daa2e06c2270497adfc07370f9dbb..4a45b5d4fb6cf28bcb48f9ec65ba9b7a54e6f7bf 100644 --- a/test/unitTest/conversationRepository/conversationRepository.cpp +++ b/test/unitTest/conversationRepository/conversationRepository.cpp @@ -61,6 +61,16 @@ private: void testAddSomeMessages(); void testLogMessages(); void testFetch(); + void testMerge(); + void testFFMerge(); + + std::string addCommit(git_repository* repo, + const std::shared_ptr<JamiAccount> account, + const std::string& branch, + const std::string& commit_msg); + bool merge_in_main(const std::shared_ptr<JamiAccount> account, + git_repository* repo, + const std::string& commit_ref); CPPUNIT_TEST_SUITE(ConversationRepositoryTest); CPPUNIT_TEST(testCreateRepository); @@ -68,6 +78,8 @@ private: CPPUNIT_TEST(testAddSomeMessages); CPPUNIT_TEST(testLogMessages); CPPUNIT_TEST(testFetch); + CPPUNIT_TEST(testMerge); + CPPUNIT_TEST(testFFMerge); CPPUNIT_TEST_SUITE_END(); }; @@ -198,9 +210,9 @@ ConversationRepositoryTest::testCloneViaChannelSocket() { auto aliceAccount = Manager::instance().getAccount<JamiAccount>(aliceId); auto bobAccount = Manager::instance().getAccount<JamiAccount>(bobId); - auto aliceDeviceId = aliceAccount->currentDeviceId(); + auto aliceDeviceId = std::string(aliceAccount->currentDeviceId()); auto uri = aliceAccount->getUsername(); - auto bobDeviceId = bobAccount->currentDeviceId(); + auto bobDeviceId = std::string(bobAccount->currentDeviceId()); bobAccount->connectionManager().onICERequest([](const DeviceId&) { return true; }); aliceAccount->connectionManager().onICERequest([](const DeviceId&) { return true; }); @@ -381,8 +393,8 @@ ConversationRepositoryTest::testFetch() { auto aliceAccount = Manager::instance().getAccount<JamiAccount>(aliceId); auto bobAccount = Manager::instance().getAccount<JamiAccount>(bobId); - auto aliceDeviceId = aliceAccount->currentDeviceId(); - auto bobDeviceId = bobAccount->currentDeviceId(); + auto aliceDeviceId = std::string(aliceAccount->currentDeviceId()); + auto bobDeviceId = std::string(bobAccount->currentDeviceId()); bobAccount->connectionManager().onICERequest([](const DeviceId&) { return true; }); aliceAccount->connectionManager().onICERequest([](const DeviceId&) { return true; }); @@ -506,6 +518,161 @@ ConversationRepositoryTest::testFetch() messages[2].signature)); } +std::string +ConversationRepositoryTest::addCommit(git_repository* repo, + const std::shared_ptr<JamiAccount> account, + const std::string& branch, + const std::string& commit_msg) +{ + auto deviceId = std::string(account->currentDeviceId()); + auto name = account->getDisplayName(); + if (name.empty()) + name = deviceId; + + git_signature* sig_ptr = nullptr; + // Sign commit's buffer + if (git_signature_new(&sig_ptr, name.c_str(), deviceId.c_str(), std::time(nullptr), 0) < 0) { + JAMI_ERR("Unable to create a commit signature."); + return {}; + } + GitSignature sig {sig_ptr, git_signature_free}; + + // Retrieve current HEAD + git_oid commit_id; + if (git_reference_name_to_id(&commit_id, repo, "HEAD") < 0) { + JAMI_ERR("Cannot get reference for HEAD"); + return {}; + } + + git_commit* head_ptr = nullptr; + if (git_commit_lookup(&head_ptr, repo, &commit_id) < 0) { + JAMI_ERR("Could not look up HEAD commit"); + return {}; + } + GitCommit head_commit {head_ptr, git_commit_free}; + + git_tree* tree_ptr = nullptr; + if (git_commit_tree(&tree_ptr, head_commit.get()) < 0) { + JAMI_ERR("Could not look up initial tree"); + return {}; + } + GitTree tree {tree_ptr, git_tree_free}; + + git_buf to_sign = {}; + const git_commit* head_ref[1] = {head_commit.get()}; + if (git_commit_create_buffer(&to_sign, + repo, + sig.get(), + sig.get(), + nullptr, + commit_msg.c_str(), + tree.get(), + 1, + &head_ref[0]) + < 0) { + JAMI_ERR("Could not create commit buffer"); + return {}; + } + + // git commit -S + auto to_sign_vec = std::vector<uint8_t>(to_sign.ptr, to_sign.ptr + to_sign.size); + auto signed_buf = account->identity().first->sign(to_sign_vec); + std::string signed_str = base64::encode(signed_buf.begin(), signed_buf.end()); + if (git_commit_create_with_signature(&commit_id, + repo, + to_sign.ptr, + signed_str.c_str(), + "signature") + < 0) { + JAMI_ERR("Could not sign commit"); + return {}; + } + + auto commit_str = git_oid_tostr_s(&commit_id); + if (commit_str) { + JAMI_INFO("New commit added with id: %s", commit_str); + // Move commit to main branch + git_reference* ref_ptr = nullptr; + std::string branch_name = "refs/heads/" + branch; + if (git_reference_create(&ref_ptr, repo, branch_name.c_str(), &commit_id, true, nullptr) + < 0) { + JAMI_WARN("Could not move commit to main"); + } + git_reference_free(ref_ptr); + } + return commit_str ? commit_str : ""; +} + +void +ConversationRepositoryTest::testMerge() +{ + auto aliceAccount = Manager::instance().getAccount<JamiAccount>(aliceId); + auto repository = ConversationRepository::createConversation(aliceAccount->weak()); + + // Assert that repository exists + CPPUNIT_ASSERT(repository != nullptr); + auto repoPath = fileutils::get_data_dir() + DIR_SEPARATOR_STR + aliceAccount->getAccountID() + + DIR_SEPARATOR_STR + "conversations" + DIR_SEPARATOR_STR + repository->id(); + CPPUNIT_ASSERT(fileutils::isDirectory(repoPath)); + + // Assert that first commit is signed by alice + git_repository* repo; + CPPUNIT_ASSERT(git_repository_open(&repo, repoPath.c_str()) == 0); + auto id1 = addCommit(repo, aliceAccount, "main", "Commit 1"); + + git_reference* ref = nullptr; + git_commit* commit = nullptr; + git_oid commit_id; + git_oid_fromstr(&commit_id, repository->id().c_str()); + git_commit_lookup(&commit, repo, &commit_id); + git_branch_create(&ref, repo, "to_merge", commit, false); + git_reference_free(ref); + git_repository_set_head(repo, "refs/heads/to_merge"); + + auto id2 = addCommit(repo, aliceAccount, "to_merge", "Commit 2"); + git_repository_free(repo); + + // This will create a merge commit + repository->merge(id2); + + CPPUNIT_ASSERT(repository->log().size() == 4 /* Initial, commit 1, 2, merge */); +} + +void +ConversationRepositoryTest::testFFMerge() +{ + auto aliceAccount = Manager::instance().getAccount<JamiAccount>(aliceId); + auto repository = ConversationRepository::createConversation(aliceAccount->weak()); + + // Assert that repository exists + CPPUNIT_ASSERT(repository != nullptr); + auto repoPath = fileutils::get_data_dir() + DIR_SEPARATOR_STR + aliceAccount->getAccountID() + + DIR_SEPARATOR_STR + "conversations" + DIR_SEPARATOR_STR + repository->id(); + CPPUNIT_ASSERT(fileutils::isDirectory(repoPath)); + + // Assert that first commit is signed by alice + git_repository* repo; + CPPUNIT_ASSERT(git_repository_open(&repo, repoPath.c_str()) == 0); + auto id1 = addCommit(repo, aliceAccount, "main", "Commit 1"); + + git_reference* ref = nullptr; + git_commit* commit = nullptr; + git_oid commit_id; + git_oid_fromstr(&commit_id, id1.c_str()); + git_commit_lookup(&commit, repo, &commit_id); + git_branch_create(&ref, repo, "to_merge", commit, false); + git_reference_free(ref); + git_repository_set_head(repo, "refs/heads/to_merge"); + + auto id2 = addCommit(repo, aliceAccount, "to_merge", "Commit 2"); + git_repository_free(repo); + + // This will use a fast forward merge + repository->merge(id2); + + CPPUNIT_ASSERT(repository->log().size() == 3 /* Initial, commit 1, 2 */); +} + } // namespace test } // namespace jami