From f77d18cb63d960b9f27a40e5070d970788c15a87 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Thu, 22 Oct 2026 16:21:19 -0400 Subject: [PATCH] =?UTF-8?q?fix(6245):=20a=20re-run=20of=20bind=5Frepo(move?= =?UTF-8?q?d=5Ffrom=3D=E2=80=A6)=20finishes=20an=20earlier=20move?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Once the binding has moved, the old remote is no longer bound, and move_binding refused. An install that renamed its repo before snippet locations moved with it had no way to move them. When the old remote is unbound and the new one is bound to this project, bind_repo now skips the binding move and still moves the snippet locations that name the old repo. The snippet-move test gives each location its own path: rewritten to one repo, identical locations merge into one. Co-Authored-By: Claude Opus 5.5 --- src/grimoire/mcp/tools/repos.py | 17 +++++++++++---- tests/test_integration_repo_move.py | 33 ++++++++++++++++++++++++++++- 2 files changed, 45 insertions(+), 5 deletions(-) diff --git a/src/grimoire/mcp/tools/repos.py b/src/grimoire/mcp/tools/repos.py index a63faf39..c1d86c75 100644 --- a/src/grimoire/mcp/tools/repos.py +++ b/src/grimoire/mcp/tools/repos.py @@ -34,8 +34,10 @@ async def bind_repo( under the old one. Pass the old remote as `moved_from` and the binding, every shape judgment, and every snippet location recorded in the old repo move to the new name together. Binding the new name without it starts an - empty ledger beside the old one. A snippet shared with you read-only keeps - its location and is listed in `snippets_read_only`; its owner moves it. + empty ledger beside the old one. Running it again once the binding has + moved moves only what still names the old repo. A snippet shared with you + read-only keeps its location and is listed in `snippets_read_only`; its + owner moves it. Args: repo_url: the repo's git remote (e.g. the output of @@ -54,9 +56,16 @@ async def bind_repo( ref_arg = None if not ref else ("" if ref.strip() == "-" else ref) moved = snippets = None if moved_from: - _, moved = await repo_bindings_svc.move_binding( - uid, moved_from, repo_url, project_id, + already = ( + await repo_bindings_svc.resolve_project(uid, moved_from) is None + and await repo_bindings_svc.resolve_project(uid, repo_url) == project_id ) + if already: + moved = 0 + else: + _, moved = await repo_bindings_svc.move_binding( + uid, moved_from, repo_url, project_id, + ) snippets = await snippets_svc.move_location_repo( uid, project_id, moved_from, repo_url, ) diff --git a/tests/test_integration_repo_move.py b/tests/test_integration_repo_move.py index 700ddad8..ee102a19 100644 --- a/tests/test_integration_repo_move.py +++ b/tests/test_integration_repo_move.py @@ -112,7 +112,12 @@ async def test_the_snippets_recorded_in_the_old_repo_move_with_it(bound): note = await snippets_svc.create_snippet( owner, name=name, code=f"def {name}():\n pass\n", language="python", project_id=pid, - locations=[{"repo": r, "path": "src/app.py", "symbol": name} for r in repos], + # A path per location: rewritten to one repo, identical locations + # would merge into one. + locations=[ + {"repo": r, "path": f"src/app{i}.py", "symbol": name} + for i, r in enumerate(repos) + ], ) return int(note.id) @@ -132,3 +137,29 @@ async def test_the_snippets_recorded_in_the_old_repo_move_with_it(bound): assert await repos(moved_one) == [NEW_KEY, NEW_KEY, NEW_KEY] assert await repos(mixed) == [NEW_KEY, "elsewhere/gadget"] assert await repos(untouched) == ["Gadget", "git.example.com/alice/oldnamesake"] + + +@pytest.mark.integration +async def test_running_the_move_again_finishes_what_an_earlier_one_left(bound): + from grimoire.mcp._context import _user_id_ctx + from grimoire.mcp.tools.repos import bind_repo + from grimoire.services import snippets as snippets_svc + + owner, pid = bound["owner"], bound["pid"] + await svc.move_binding(owner, OLD, NEW, pid) + note = await snippets_svc.create_snippet( + owner, name="left_behind", code="def left_behind():\n pass\n", + language="python", project_id=pid, + locations=[{"repo": "OldName", "path": "src/app.py", "symbol": "left_behind"}], + ) + + token = _user_id_ctx.set(owner) + try: + out = await bind_repo(NEW, pid, moved_from=OLD) + finally: + _user_id_ctx.reset(token) + + assert out["shapes_moved"] == 0 + assert out["snippets_moved"] == [int(note.id)] + moved = await snippets_svc.get_snippet(owner, int(note.id)) + assert snippets_svc.snippet_fields(moved)["locations"][0]["repo"] == NEW_KEY