launchpad-reviewers team mailing list archive
-
launchpad-reviewers team
-
Mailing list archive
-
Message #32457
[Merge] ~ines-almeida/turnip:add-merge-api into turnip:master
Ines Almeida has proposed merging ~ines-almeida/turnip:add-merge-api into turnip:master.
Commit message:
Add new merge API
The new API allows for doing regular merges with a merge commit. Other merge strategies would need to be implemented separately
Requested reviews:
Launchpad code reviewers (launchpad-reviewers)
For more details, see:
https://code.launchpad.net/~ines-almeida/turnip/+git/turnip/+merge/485401
--
Your team Launchpad code reviewers is requested to review the proposed merge of ~ines-almeida/turnip:add-merge-api into turnip:master.
diff --git a/turnip/api/store.py b/turnip/api/store.py
index 16d324b..a24b5aa 100644
--- a/turnip/api/store.py
+++ b/turnip/api/store.py
@@ -23,6 +23,7 @@ from pygit2 import (
InvalidSpecError,
Oid,
Repository,
+ Signature,
init_repository,
)
from twisted.web import xmlrpc
@@ -711,6 +712,96 @@ def get_merge_diff(
}
+class MergeConflicts(Exception):
+ pass
+
+
+class BranchNotFoundError(Exception):
+ pass
+
+
+def merge(
+ repo_store,
+ repo_name,
+ target_branch,
+ source_branch,
+ committer_name,
+ committer_email,
+ commit_message=None,
+):
+ """Do a regular merge from source branch into target branch.
+
+ This currently only supports a regular merge with a merge commit, other
+ merge strategies still need to be implemented.
+
+ :param repo_store: path to the repository store
+ :param repo_name: name of the repository
+ :param target_branch: target branch to merge into
+ :param source_branch: source branch to merge from
+ :param committer_name: name of the committer
+ :param committer_email: email of the committer
+ :param commit_message: [optional] custom commit message
+ """
+
+ with open_repo(repo_store, repo_name) as repo:
+ try:
+ target_ref = repo.lookup_reference(f"refs/heads/{target_branch}")
+ source_ref = repo.lookup_reference(f"refs/heads/{source_branch}")
+ target_tip = repo[target_ref.target]
+ source_tip = repo[source_ref.target]
+ except (KeyError, ValueError) as e:
+ raise BranchNotFoundError(f"Branch not found: {str(e)}")
+
+ original_target_tip = target_ref.target
+
+ # Check if source is already included in target
+ common_ancestor_id = repo.merge_base(target_tip.oid, source_tip.oid)
+ if common_ancestor_id == source_ref.target:
+ return {"merge_commit": None}
+
+ # Create an in-memory index for the merge
+ index = repo.merge_commits(target_tip, source_tip)
+ if index.conflicts is not None:
+ raise MergeConflicts("Merge conflicts detected")
+
+ tree_id = index.write_tree(repo)
+
+ committer = Signature(committer_name, committer_email)
+ if commit_message is None:
+ commit_message = (
+ f"Merge branch '{source_branch}' into {target_branch}"
+ )
+
+ # Verify that branch hasn't changed since the start of the merge
+ current_target_ref = repo.lookup_reference(
+ f"refs/heads/{target_branch}"
+ )
+ if original_target_tip != current_target_ref.target:
+ raise GitError("Target branch was modified during operation")
+
+ # Create a merge commit that has both branch tips as parents to
+ # preserve the commit history.
+ #
+ # This is the only write operation in this function. Since it's
+ # a single atomic operation, we don't need additional safety
+ # mechanisms: if the operation fails, no changes are made; if it
+ # succeeds, the merge is complete.
+ #
+ # Note also that `create_commit` will raise a GitError a new
+ # commit is pushed to the target branch since the start of this
+ # merge.
+ merge_commit = repo.create_commit(
+ target_ref.name,
+ committer,
+ committer,
+ commit_message,
+ tree_id,
+ [target_ref.target, source_ref.target],
+ )
+
+ return {"merge_commit": merge_commit.hex}
+
+
def get_diff(repo_store, repo_name, sha1_from, sha1_to, context_lines=3):
"""Get patch and associated commits of two sha1s.
diff --git a/turnip/api/tests/test_api.py b/turnip/api/tests/test_api.py
index 9bcfda0..fed5546 100644
--- a/turnip/api/tests/test_api.py
+++ b/turnip/api/tests/test_api.py
@@ -1456,6 +1456,197 @@ class ApiTestCase(TestCase, ApiRepoStoreMixin):
)
self.assertEqual(404, resp.status_code)
+ def test_merge_successful(self):
+ """Test a successful merge between two branches."""
+ factory = RepoFactory(self.repo_store)
+ initial_commit = factory.add_commit("initial", "file.txt")
+ repo = factory.build()
+ repo.create_branch("main", repo.get(initial_commit))
+ repo.set_head("refs/heads/main")
+
+ feature_commit = factory.add_commit(
+ "feature", "file.txt", parents=[initial_commit]
+ )
+ repo.create_branch("feature", repo.get(feature_commit))
+
+ resp = self.app.post_json(
+ f"/repo/{self.repo_path}/merge/main:feature",
+ {
+ "committer_name": "Test User",
+ "committer_email": "test@xxxxxxxxxxx",
+ },
+ )
+
+ self.assertEqual(200, resp.status_code)
+ self.assertIsNotNone(resp.json["merge_commit"])
+
+ merge_commit = repo.get(resp.json["merge_commit"])
+ self.assertEqual(merge_commit.parents[0].hex, initial_commit.hex)
+ self.assertEqual(merge_commit.parents[1].hex, feature_commit.hex)
+
+ self.assertEqual(
+ repo.references["refs/heads/main"].target.hex,
+ resp.json["merge_commit"],
+ )
+
+ def test_merge_already_included(self):
+ """Test merge when source is already included in target."""
+ factory = RepoFactory(self.repo_store)
+ initial_commit = factory.add_commit("initial", "file.txt")
+ repo = factory.build()
+ repo.create_branch("main", repo.get(initial_commit))
+ repo.set_head("refs/heads/main")
+
+ feature_commit = factory.add_commit(
+ "feature", "file.txt", parents=[initial_commit]
+ )
+ repo.create_branch("feature", repo.get(feature_commit))
+
+ self.app.post_json(
+ f"/repo/{self.repo_path}/merge/main:feature",
+ {
+ "committer_name": "Test User",
+ "committer_email": "test@xxxxxxxxxxx",
+ },
+ )
+
+ # Try to merge again
+ resp = self.app.post_json(
+ f"/repo/{self.repo_path}/merge/main:feature",
+ {
+ "committer_name": "Test User",
+ "committer_email": "test@xxxxxxxxxxx",
+ },
+ )
+
+ self.assertEqual(200, resp.status_code)
+ self.assertIsNone(resp.json["merge_commit"])
+
+ def test_merge_conflicts(self):
+ """Test merge with conflicts."""
+ factory = RepoFactory(self.repo_store)
+ initial_commit = factory.add_commit("initial", "file.txt")
+ repo = factory.build()
+ repo.create_branch("main", repo.get(initial_commit))
+ repo.set_head("refs/heads/main")
+
+ # Create conflicting changes
+ main_commit = factory.add_commit(
+ "main change", "file.txt", parents=[initial_commit]
+ )
+ repo.references["refs/heads/main"].set_target(main_commit)
+ feature_commit = factory.add_commit(
+ "feature change", "file.txt", parents=[initial_commit]
+ )
+ repo.create_branch("feature", repo.get(feature_commit))
+
+ resp = self.app.post_json(
+ f"/repo/{self.repo_path}/merge/main:feature",
+ {
+ "committer_name": "Test User",
+ "committer_email": "test@xxxxxxxxxxx",
+ },
+ expect_errors=True,
+ )
+
+ self.assertEqual(400, resp.status_code)
+ self.assertIn(
+ "Found conflicts between target and source branches",
+ resp.text,
+ )
+
+ def test_merge_missing_branches(self):
+ """Test merge with missing branches."""
+ factory = RepoFactory(self.repo_store)
+ initial_commit = factory.add_commit("initial", "file.txt")
+ repo = factory.build()
+ repo.create_branch("main", repo.get(initial_commit))
+ repo.set_head("refs/heads/main")
+
+ resp = self.app.post_json(
+ f"/repo/{self.repo_path}/merge/main:nonexisting",
+ {
+ "committer_name": "Test User",
+ "committer_email": "test@xxxxxxxxxxx",
+ },
+ expect_errors=True,
+ )
+
+ self.assertEqual(404, resp.status_code)
+
+ def test_merge_custom_message(self):
+ """Test merge with custom commit message."""
+ factory = RepoFactory(self.repo_store)
+ initial_commit = factory.add_commit("initial", "file.txt")
+ repo = factory.build()
+ repo.create_branch("main", repo.get(initial_commit))
+ repo.set_head("refs/heads/main")
+
+ feature_commit = factory.add_commit(
+ "feature", "file.txt", parents=[initial_commit]
+ )
+ repo.create_branch("feature", repo.get(feature_commit))
+
+ custom_message = "Custom merge message"
+ resp = self.app.post_json(
+ f"/repo/{self.repo_path}/merge/main:feature",
+ {
+ "committer_name": "Test User",
+ "committer_email": "test@xxxxxxxxxxx",
+ "commit_message": custom_message,
+ },
+ )
+
+ self.assertEqual(200, resp.status_code)
+ merge_commit = repo.get(resp.json["merge_commit"])
+ self.assertEqual(merge_commit.message, custom_message)
+
+ def test_merge_no_commit_message(self):
+ """Test merge without a custom commit message."""
+ factory = RepoFactory(self.repo_store)
+ initial_commit = factory.add_commit("initial", "file.txt")
+ repo = factory.build()
+ repo.create_branch("main", repo.get(initial_commit))
+ repo.set_head("refs/heads/main")
+
+ feature_commit = factory.add_commit(
+ "feature", "file.txt", parents=[initial_commit]
+ )
+ repo.create_branch("feature", repo.get(feature_commit))
+
+ resp = self.app.post_json(
+ f"/repo/{self.repo_path}/merge/main:feature",
+ {
+ "committer_name": "Test User",
+ "committer_email": "test@xxxxxxxxxxx",
+ },
+ )
+
+ self.assertEqual(200, resp.status_code)
+ merge_commit = repo.get(resp.json["merge_commit"])
+ self.assertEqual(
+ merge_commit.message, "Merge branch 'feature' into main"
+ )
+
+ def test_merge_invalid_input(self):
+ """Test merge with invalid input."""
+ factory = RepoFactory(self.repo_store)
+ initial_commit = factory.add_commit("initial", "file.txt")
+ repo = factory.build()
+ repo.create_branch("main", repo.get(initial_commit))
+ repo.set_head("refs/heads/main")
+
+ resp = self.app.post_json(
+ f"/repo/{self.repo_path}/merge/main:feature",
+ {
+ # Missing committer_email
+ "committer_name": "Test User",
+ },
+ expect_errors=True,
+ )
+
+ self.assertEqual(400, resp.status_code)
+
class AsyncRepoCreationAPI(TestCase, ApiRepoStoreMixin):
def setUp(self):
diff --git a/turnip/api/tests/test_store.py b/turnip/api/tests/test_store.py
index 647c48b..900fd14 100644
--- a/turnip/api/tests/test_store.py
+++ b/turnip/api/tests/test_store.py
@@ -670,3 +670,130 @@ class InitTestCase(TestCase):
celery_fixture.waitUntil(
10, lambda: self.hasZeroLooseObjects(orig_path)
)
+
+
+class MergeTestCase(TestCase):
+ def setUp(self):
+ super().setUp()
+ self.repo_store = self.useFixture(TempDir()).path
+ self.useFixture(EnvironmentVariable("REPO_STORE", self.repo_store))
+ self.repo_path = os.path.join(self.repo_store, "repo")
+ self.factory = RepoFactory(self.repo_path)
+ self.repo = self.factory.build()
+
+ self.initial_commit = self.factory.add_commit("initial", "file.txt")
+ self.repo.create_branch("main", self.repo.get(self.initial_commit))
+ self.repo.set_head("refs/heads/main")
+
+ self.feature_commit = self.factory.add_commit(
+ "feature", "file.txt", parents=[self.initial_commit]
+ )
+ self.repo.create_branch("feature", self.repo.get(self.feature_commit))
+
+ def test_merge_successful(self):
+ """Test a successful merge between two branches."""
+ result = store.merge(
+ self.repo_store,
+ "repo",
+ "main",
+ "feature",
+ "Test User",
+ "test@xxxxxxxxxxx",
+ )
+
+ self.assertIsNotNone(result["merge_commit"])
+ merge_commit = self.repo.get(result["merge_commit"])
+ self.assertEqual(merge_commit.parents[0].hex, self.initial_commit.hex)
+ self.assertEqual(merge_commit.parents[1].hex, self.feature_commit.hex)
+
+ self.assertEqual(
+ self.repo.references["refs/heads/main"].target.hex,
+ result["merge_commit"],
+ )
+
+ def test_merge_already_included(self):
+ """Test merge when source is already included in target."""
+ store.merge(
+ self.repo_store,
+ "repo",
+ "main",
+ "feature",
+ "Test User",
+ "test@xxxxxxxxxxx",
+ )
+
+ # Try to merge again
+ result = store.merge(
+ self.repo_store,
+ "repo",
+ "main",
+ "feature",
+ "Test User",
+ "test@xxxxxxxxxxx",
+ )
+ self.assertIsNone(result["merge_commit"])
+
+ def test_merge_conflicts(self):
+ """Test merge with conflicts."""
+ main_commit = self.factory.add_commit(
+ "main content", "file.txt", parents=[self.initial_commit]
+ )
+ self.repo.references["refs/heads/main"].set_target(main_commit)
+
+ feature_commit = self.factory.add_commit(
+ "feature content", "file.txt", parents=[self.initial_commit]
+ )
+ self.repo.references["refs/heads/feature"].set_target(feature_commit)
+
+ self.assertRaises(
+ store.MergeConflicts,
+ store.merge,
+ self.repo_store,
+ "repo",
+ "main",
+ "feature",
+ "Test User",
+ "test@xxxxxxxxxxx",
+ )
+
+ def test_merge_custom_message(self):
+ """Test merge with custom commit message."""
+ custom_message = "Custom merge message"
+ result = store.merge(
+ self.repo_store,
+ "repo",
+ "main",
+ "feature",
+ "Test User",
+ "test@xxxxxxxxxxx",
+ commit_message=custom_message,
+ )
+
+ merge_commit = self.repo.get(result["merge_commit"])
+ self.assertEqual(merge_commit.message, custom_message)
+
+ def test_merge_with_invalid_branch_names(self):
+ """Test error handling for invalid branch names."""
+ self.assertRaises(
+ store.BranchNotFoundError,
+ store.merge,
+ self.repo_store,
+ "repo",
+ "main",
+ "invalid/branch/name",
+ "Test User",
+ "test@xxxxxxxxxxx",
+ )
+
+ def test_merge_with_nonexistent_branches(self):
+ """Test error handling when branches don't exist."""
+ self.assertRaises(
+ store.BranchNotFoundError,
+ store.merge,
+ self.repo_store,
+ "repo",
+ "nonexistent",
+ "feature",
+ "Test User",
+ "test@xxxxxxxxxxx",
+ )
diff --git a/turnip/api/views.py b/turnip/api/views.py
index bd5b9fd..84c3a94 100644
--- a/turnip/api/views.py
+++ b/turnip/api/views.py
@@ -446,6 +446,61 @@ class DiffMergeAPI(BaseAPI):
return patch
+@resource(path="/repo/{name}/merge/{target_branch}:{source_branch}")
+class MergeAPI(BaseAPI):
+ """Provides an HTTP API for merging.
+
+ {source_branch} will be merged into {target_branch} within
+ repo {name} (currently we don't support cross-repo)
+ """
+
+ def __init__(self, request, context=None):
+ super().__init__()
+ self.request = request
+
+ @validate_path
+ def post(self, repo_store, repo_name):
+ """Merge a source branch into a target branch.
+
+ This endpoint assumes that the committer is authorized to perform this
+ merge, i.e., it does not check for permissions. The authorization will
+ lie on the system that makes the request (e.g. Launchpad).
+ """
+ committer_name = self.request.json.get("committer_name")
+ committer_email = self.request.json.get("committer_email")
+ commit_message = self.request.json.get("commit_message")
+
+ if not committer_name or not committer_email:
+ return exc.HTTPBadRequest("Committer name and email are required")
+
+ # TODO ines-almeida 2025-04-30 we are starting with only allowing
+ # merging branches within the same repo. In Launchpad, it's very common
+ # that users what to merge cross-repo, so this is something we would
+ # need to implement soon for this to be a useful feature.
+ if len(repo_name.split(":")) > 1:
+ return exc.HTTPBadRequest("We don't yet allow cross-repo merges")
+
+ try:
+ response = store.merge(
+ repo_store,
+ repo_name,
+ self.request.matchdict["target_branch"],
+ self.request.matchdict["source_branch"],
+ committer_name,
+ committer_email,
+ commit_message,
+ )
+ except store.MergeConflicts:
+ return exc.HTTPBadRequest(
+ "Found conflicts between target and source branches"
+ )
+ except store.BranchNotFoundError:
+ return exc.HTTPNotFound()
+ except GitError:
+ return exc.HTTPBadRequest()
+ return response
+
+
@resource(
collection_path="/repo/{name}/commits", path="/repo/{name}/commits/{sha1}"
)
Follow ups