launchpad-reviewers team mailing list archive
-
launchpad-reviewers team
-
Mailing list archive
-
Message #32589
[Merge] ~ines-almeida/turnip:add-cross-repo-merge into turnip:master
Ines Almeida has proposed merging ~ines-almeida/turnip:add-cross-repo-merge into turnip:master.
Commit message:
Add cross-repo merge functionality
When dealing with cross-repo merges, turnip pushes the source branch to the target repo, merges and then cleans up the added ref.
Requested reviews:
Launchpad code reviewers (launchpad-reviewers)
For more details, see:
https://code.launchpad.net/~ines-almeida/turnip/+git/turnip/+merge/486519
--
Your team Launchpad code reviewers is requested to review the proposed merge of ~ines-almeida/turnip:add-cross-repo-merge into turnip:master.
diff --git a/turnip/api/store.py b/turnip/api/store.py
index 197a4d9..b2c4320 100644
--- a/turnip/api/store.py
+++ b/turnip/api/store.py
@@ -22,6 +22,7 @@ from pygit2 import (
IndexEntry,
InvalidSpecError,
Oid,
+ RemoteCallbacks,
Repository,
Signature,
init_repository,
@@ -724,17 +725,98 @@ class RefNotFoundError(Exception):
pass
-def getBranchTip(repo, branch_name):
+def get_branch_tip(repo, branch_ref):
"""Search for a branch within a repo.
Returns the Oid of the branch's last commit"""
+ if not branch_ref.startswith("refs/"):
+ branch_ref = f"refs/heads/{branch_ref}"
+
try:
- ref = repo.lookup_reference(f"refs/heads/{branch_name}")
+ ref = repo.lookup_reference(branch_ref)
return ref.target
except (KeyError, ValueError) as e:
raise RefNotFoundError(
- f"Branch '{branch_name}' not found in repo {repo.path}: {e}"
+ f"Branch '{branch_ref}' not found in repo {repo.path}: {e}"
+ )
+
+
+@contextmanager
+def open_remote(source_repo, target_repo_path, remote_name):
+ """Opens a remote connection from {source_repo} into a {target_repo_path}.
+
+ :param source_repo: repository where we will create the new remote.
+ :param target_repo_path: path to the target repo
+ :param remote_name: name to give to the new remote
+
+ """
+ try:
+ try:
+ remote = source_repo.remotes.create(
+ remote_name, f"file://{target_repo_path}"
+ )
+ except ValueError:
+ # In the case where a remote wasn't removed correctly
+ source_repo.remotes.set_url(
+ remote_name, f"file://{target_repo_path}"
+ )
+ remote = source_repo.remotes[remote_name]
+ yield remote
+ finally:
+ source_repo.remotes.delete(remote_name)
+
+
+def push(repo_store, source_repo_name, target_repo, source_branch):
+ remote_name = f"{source_repo_name}-{source_branch}"
+ source_ref = f"refs/heads/{source_branch}"
+ remote_ref = f"refs/internal/{remote_name}"
+
+ with open_repo(repo_store, source_repo_name) as source_repo, open_remote(
+ source_repo, target_repo.path, remote_name
+ ) as remote:
+ # Specify which ref to push (source_ref:target_ref)
+ refspec = f"{source_ref}:{remote_ref}"
+
+ # No needed credentials for local file URLs
+ callbacks = RemoteCallbacks(credentials=None)
+ remote.push([refspec], callbacks=callbacks)
+
+ return remote_ref
+
+
+def _get_target_commit(repo, target_branch, target_commit_sha1):
+ """Validate that target commit exists within the target branch, and return
+ it"""
+ target_tip = get_branch_tip(repo, target_branch)
+ if not (
+ target_tip.hex == target_commit_sha1
+ or repo.descendant_of(target_tip, target_commit_sha1)
+ ):
+ raise GitError("The target commit is not part of the target branch")
+ return target_tip
+
+
+def _get_source_commit(repo, source_branch, source_commit_sha1):
+ """Validate that source tip matches the requested commit and return it."""
+ source_tip = get_branch_tip(repo, source_branch)
+ if source_tip.hex != source_commit_sha1:
+ raise GitError("The tip of the source branch has changed")
+ return source_tip
+
+
+def _get_remote_source_tip(
+ repo_store, source_repo_name, repo, source_branch, source_commit_sha1
+):
+ """Get source commit from source repo into target repo"""
+ try:
+ # For cross repo, we push a temporary ref to the target repo
+ source_ref_name = push(
+ repo_store, source_repo_name, repo, source_branch
)
+ return _get_source_commit(repo, source_ref_name, source_commit_sha1)
+ finally:
+ # Cleanup temporary refs
+ repo.references.delete(source_ref_name)
def merge(
@@ -764,22 +846,30 @@ def merge(
:param commit_message: [optional] custom commit message
"""
+ source_repo_name = None
+ if len(repo_name.split(":")) == 2:
+ repo_name, source_repo_name = repo_name.split(":")
+ if repo_name == source_repo_name:
+ source_repo_name = None
+
+ is_cross_repo = source_repo_name is not None
+
with open_repo(repo_store, repo_name) as repo:
- target_tip = getBranchTip(repo, target_branch)
- source_tip = getBranchTip(repo, source_branch)
-
- # Check source tip is still the same as when the merge was requested
- if source_tip.hex != source_commit_sha1:
- raise GitError("The tip of the source branch has changed")
-
- # Check target_commit_sha1 exists within the target branch.
- # We fail the merge if the target branch was re-written
- if not (
- target_tip.hex == target_commit_sha1
- or repo.descendant_of(target_tip, target_commit_sha1)
- ):
- raise GitError(
- "The target commit is not part of the target branch"
+ target_tip = _get_target_commit(
+ repo, target_branch, target_commit_sha1
+ )
+
+ if is_cross_repo:
+ source_tip = _get_remote_source_tip(
+ repo_store,
+ source_repo_name,
+ repo,
+ source_branch,
+ source_commit_sha1,
+ )
+ else:
+ source_tip = _get_source_commit(
+ repo, source_branch, source_commit_sha1
)
# Check if source is already included in target
@@ -804,22 +894,17 @@ def merge(
)
# Verify that branch hasn't changed since the start of the merge
- target_ref = f"refs/heads/{target_branch}"
- current_target_ref = repo.lookup_reference(target_ref)
- if target_tip != current_target_ref.target:
+ current_target_tip = get_branch_tip(repo, target_branch)
+ if target_tip != current_target_tip:
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 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 if a new
+ # Note that `create_commit` will raise a GitError if a new
# commit is pushed to the target branch since the start of this
# merge.
+ target_ref = f"refs/heads/{target_branch}"
merge_commit = repo.create_commit(
target_ref,
committer,
diff --git a/turnip/api/tests/test_api.py b/turnip/api/tests/test_api.py
index 04330c7..863d62f 100644
--- a/turnip/api/tests/test_api.py
+++ b/turnip/api/tests/test_api.py
@@ -1492,6 +1492,58 @@ class ApiTestCase(TestCase, ApiRepoStoreMixin):
self.assertEqual(merge_commit.committer.name, "Test User")
self.assertEqual(merge_commit.committer.email, "test@xxxxxxxxxxx")
+ def test_cross_repo_merge_successful(self):
+ """Test a successful cross-repo merge."""
+ # Create target repo with main branch
+ target_path = os.path.join(self.repo_root, "target")
+ target_factory = RepoFactory(target_path)
+ target_repo = target_factory.build()
+ target_initial = target_factory.add_commit(
+ "target initial", "file.txt"
+ )
+ target_repo.create_branch("main", target_repo.get(target_initial))
+ target_repo.set_head("refs/heads/main")
+
+ # Create source repo with feature branch
+ source_path = os.path.join(self.repo_root, "source")
+ source_factory = RepoFactory(source_path, clone_from=target_factory)
+ source_repo = source_factory.build()
+ source_initial = target_initial
+ source_commit = source_factory.add_commit(
+ "source change", "file.txt", parents=[source_initial]
+ )
+ source_repo.create_branch("feature", source_repo.get(source_commit))
+
+ # Perform cross-repo merge
+ response = self.app.post_json(
+ "/repo/target:source/merge/main:feature",
+ {
+ "target_commit_sha1": target_initial.hex,
+ "source_commit_sha1": source_commit.hex,
+ "committer_name": "Test User",
+ "committer_email": "test@xxxxxxxxxxx",
+ },
+ )
+
+ self.assertEqual(response.status_code, 200)
+ self.assertIn("merge_commit", response.json)
+ self.assertIsNotNone(response.json["merge_commit"])
+
+ # Verify merge commit
+ merge_commit = target_repo.get(response.json["merge_commit"])
+ self.assertEqual(merge_commit.parents[0].hex, target_initial.hex)
+ self.assertEqual(merge_commit.parents[1].hex, source_commit.hex)
+ self.assertEqual(merge_commit.committer.name, "Test User")
+ self.assertEqual(merge_commit.committer.email, "test@xxxxxxxxxxx")
+
+ # Verify temporary ref was cleaned up
+ self.assertNotIn(
+ "refs/internal/source-feature", target_repo.references
+ )
+
+ # Verify temporary remote was cleaned up
+ self.assertEqual(0, len(target_repo.remotes))
+
def test_merge_already_included(self):
"""Test merge when source is already included in target."""
factory = RepoFactory(self.repo_store)
diff --git a/turnip/api/tests/test_store.py b/turnip/api/tests/test_store.py
index 3e3a389..03310aa 100644
--- a/turnip/api/tests/test_store.py
+++ b/turnip/api/tests/test_store.py
@@ -919,7 +919,7 @@ class GetBranchTipTestCase(TestCase):
)
# Get the branch tip
- tip_oid = store.getBranchTip(self.repo, "test_branch")
+ tip_oid = store.get_branch_tip(self.repo, "test_branch")
# Verify the tip matches our commit
self.assertEqual(tip_oid, commit_oid)
@@ -928,9 +928,256 @@ class GetBranchTipTestCase(TestCase):
"""Test getting the tip of a non-existent branch."""
e = self.assertRaises(
store.RefNotFoundError,
- store.getBranchTip,
+ store.get_branch_tip,
self.repo,
"nonexistent_branch",
)
- self.assertIn("Branch 'nonexistent_branch' not found", str(e))
+ self.assertIn(
+ "Branch 'refs/heads/nonexistent_branch' not found", str(e)
+ )
+
+
+class OpenRemoteTestCase(TestCase):
+ def setUp(self):
+ super().setUp()
+ self.repo_store = self.useFixture(TempDir()).path
+ self.useFixture(EnvironmentVariable("REPO_STORE", self.repo_store))
+
+ # Create source and target repos
+ self.source_path = os.path.join(self.repo_store, "source")
+ self.target_path = os.path.join(self.repo_store, "target")
+ self.source_factory = RepoFactory(self.source_path)
+ self.target_factory = RepoFactory(self.target_path)
+ self.source_repo = self.source_factory.build()
+ self.target_repo = self.target_factory.build()
+
+ def test_open_remote_creates_and_removes_remote(self):
+ """Test that open_remote creates a remote and removes it after use."""
+ remote_name = "test-remote"
+
+ with store.open_remote(
+ self.source_repo, self.target_path, remote_name
+ ):
+ # Verify remote was created
+ remote = self.source_repo.remotes[remote_name]
+ self.assertEqual(remote.url, f"file://{self.target_path}")
+
+ # Verify remote was removed
+ self.assertNotIn(remote_name, self.source_repo.remotes)
+
+ def test_open_remote_existing_remote(self):
+ """Test that if a remote already exists (due to a previous clean up
+ issue), open_remote will still recreate and remove the remote."""
+ remote_name = "test-remote"
+
+ self.source_repo.remotes.create(remote_name, "file://old_target_path")
+
+ with store.open_remote(
+ self.source_repo, self.target_path, remote_name
+ ):
+ remote = self.source_repo.remotes[remote_name]
+ self.assertEqual(remote.url, f"file://{self.target_path}")
+
+ self.assertNotIn(remote_name, self.source_repo.remotes)
+
+ def test_open_remote_handles_errors(self):
+ """Test that open_remote handles errors and still cleans up."""
+ remote_name = "test-remote"
+
+ def _open_remote_error():
+ with store.open_remote(
+ self.source_repo, self.target_path, remote_name
+ ):
+ raise Exception("Test error")
+
+ self.assertRaises(Exception, _open_remote_error)
+
+ # Verify remote was still removed despite error
+ self.assertNotIn(remote_name, self.source_repo.remotes)
+
+
+class PushTestCase(TestCase):
+ def setUp(self):
+ super().setUp()
+ self.repo_store = self.useFixture(TempDir()).path
+ self.useFixture(EnvironmentVariable("REPO_STORE", self.repo_store))
+
+ # Create source and target repos
+ self.source_path = os.path.join(self.repo_store, "source")
+ self.target_path = os.path.join(self.repo_store, "target")
+ self.source_factory = RepoFactory(self.source_path)
+ self.target_factory = RepoFactory(self.target_path)
+ self.source_repo = self.source_factory.build()
+ self.target_repo = self.target_factory.build()
+
+ # Create a branch in source repo
+ self.branch_name = "feature"
+ self.initial_commit = self.source_factory.add_commit(
+ "initial", "file.txt"
+ )
+ self.source_repo.create_branch(
+ self.branch_name, self.source_repo.get(self.initial_commit)
+ )
+
+ def test_push_creates_internal_ref(self):
+ """Test that push creates an internal ref in target repo."""
+ remote_ref = store.push(
+ self.repo_store, "source", self.target_repo, self.branch_name
+ )
+
+ # Verify ref was created in target repo
+ self.assertIn(remote_ref, self.target_repo.references)
+ target_ref = self.target_repo.references[remote_ref]
+ source_ref = self.source_repo.references[
+ f"refs/heads/{self.branch_name}"
+ ]
+ self.assertEqual(target_ref.target, source_ref.target)
+
+ def test_push_updates_existing_ref(self):
+ """Test that push updates an existing ref."""
+ # First push
+ remote_ref = store.push(
+ self.repo_store, "source", self.target_repo, self.branch_name
+ )
+
+ # Add new commit to source branch
+ new_commit = self.source_factory.add_commit(
+ "new commit", "file.txt", parents=[self.initial_commit]
+ )
+ self.source_repo.references[
+ f"refs/heads/{self.branch_name}"
+ ].set_target(new_commit)
+
+ # Push again
+ store.push(
+ self.repo_store, "source", self.target_repo, self.branch_name
+ )
+
+ # Verify ref was updated
+ target_ref = self.target_repo.references[remote_ref]
+ self.assertEqual(target_ref.target, new_commit)
+
+
+class CrossRepoMergeTestCase(TestCase):
+ def setUp(self):
+ super().setUp()
+ self.repo_store = self.useFixture(TempDir()).path
+ self.useFixture(EnvironmentVariable("REPO_STORE", self.repo_store))
+
+ # Create target repo
+ self.target_path = os.path.join(self.repo_store, "target")
+ self.target_factory = RepoFactory(self.target_path)
+ self.target_repo = self.target_factory.build()
+
+ self.target_initial = self.target_factory.add_commit(
+ "target initial", "file.txt"
+ )
+ self.target_branch = self.target_repo.create_branch(
+ "main", self.target_repo.get(self.target_initial)
+ )
+ self.target_repo.set_head("refs/heads/main")
+
+ # Create source repo (forked from target)
+ self.source_path = os.path.join(self.repo_store, "source")
+ self.source_factory = RepoFactory(
+ self.source_path, clone_from=self.target_factory
+ )
+ self.source_repo = self.source_factory.build()
+ self.source_initial = self.target_initial
+ self.source_branch = self.source_repo.create_branch(
+ "feature", self.source_repo.get(self.source_initial)
+ )
+
+ def test_cross_repo_merge_successful(self):
+ """Test successful merge from source repo to target repo."""
+ # Add commits to both branches
+ source_commit = self.source_factory.add_commit(
+ "source change",
+ "file.txt",
+ parents=[self.source_initial],
+ )
+ self.source_repo.lookup_reference(self.source_branch.name).set_target(
+ source_commit
+ )
+
+ result = store.merge(
+ self.repo_store,
+ "target:source", # target:source format for cross-repo
+ "main",
+ self.target_initial.hex,
+ "feature",
+ source_commit.hex,
+ "Test User",
+ "test@xxxxxxxxxxx",
+ )
+
+ # Verify merge was successful
+ self.assertIsNotNone(result["merge_commit"])
+ merge_commit = self.target_repo.get(result["merge_commit"])
+ self.assertEqual(merge_commit.parents[0].hex, self.target_initial.hex)
+ self.assertEqual(merge_commit.parents[1].hex, source_commit.hex)
+
+ # Verify temporary ref was cleaned up
+ self.assertIsNone(
+ self.target_repo.references.get("refs/internal/source-feature")
+ )
+
+ def test_cross_repo_merge_conflicts(self):
+ """Test merge conflicts when merging from source repo."""
+ # Create conflicting changes in both repos
+ source_commit = self.source_factory.add_commit(
+ "source change", "file.txt", parents=[self.source_initial]
+ )
+ self.source_repo.lookup_reference(self.source_branch.name).set_target(
+ source_commit
+ )
+ target_commit = self.target_factory.add_commit(
+ "target change", "file.txt", parents=[self.target_initial]
+ )
+ self.target_repo.lookup_reference(self.target_branch.name).set_target(
+ target_commit
+ )
+
+ # Try to merge
+ self.assertRaises(
+ store.MergeConflicts,
+ store.merge,
+ self.repo_store,
+ "target:source",
+ "main",
+ target_commit.hex,
+ "feature",
+ source_commit.hex,
+ "Test User",
+ "test@xxxxxxxxxxx",
+ )
+
+ # Verify temporary ref was cleaned up
+ self.assertIsNone(
+ self.target_repo.references.get("refs/internal/source-feature")
+ )
+
+ def test_cross_repo_merge_source_branch_moved(self):
+ """Test error when source branch tip doesn't match expected commit."""
+ # Add commit to source branch after merge was requested
+ new_source_commit = self.source_factory.add_commit(
+ "new source", "file.txt", parents=[self.source_initial]
+ )
+ self.source_repo.references[self.source_branch.name].set_target(
+ new_source_commit
+ )
+
+ # Try to merge using old source commit
+ self.assertRaises(
+ pygit2.GitError,
+ store.merge,
+ self.repo_store,
+ "target:source",
+ "main",
+ self.target_initial.hex,
+ "feature",
+ self.source_initial.hex,
+ "Test User",
+ "test@xxxxxxxxxxx",
+ )
diff --git a/turnip/api/views.py b/turnip/api/views.py
index 2cd1d67..f3ef8ce 100644
--- a/turnip/api/views.py
+++ b/turnip/api/views.py
@@ -486,13 +486,6 @@ class MergeAPI(BaseAPI):
"committer_name and committer_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,
Follow ups