feat(6243): move_path carries a moved path's snippets with its shapes
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 17s
CI & Build / TypeScript typecheck (push) Successful in 54s
CI & Build / integration (push) Successful in 1m9s
CI & Build / Python tests (push) Successful in 2m12s
CI & Build / Build & push image (push) Successful in 31s
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 17s
CI & Build / TypeScript typecheck (push) Successful in 54s
CI & Build / integration (push) Successful in 1m9s
CI & Build / Python tests (push) Successful in 2m12s
CI & Build / Build & push image (push) Successful in 31s
Snippet locations are keyed by path too, so a snippet recorded under a moved directory reads `missing` at its next check. The tool is now move_path(project_id, from_path, to_path). It moves the ledger rows as before, and re-paths the project's snippet locations under from_path through update_snippet, so each body and its indexed mirror stay one record. A snippet the caller can only read is listed in snippets_read_only. The tool refuses only when nothing at all is recorded under from_path. The path test is the one snippets already uses (_path_touches), and the plugin version is minted for the skill edit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -170,10 +170,6 @@ async def sync_repo_shapes(
|
||||
await session.commit()
|
||||
|
||||
|
||||
def _under(path: str, prefix: str) -> bool:
|
||||
return path == prefix or path.startswith(prefix + "/")
|
||||
|
||||
|
||||
async def move_shapes(
|
||||
user_id: int, project_id: int, from_path: str, to_path: str,
|
||||
) -> dict:
|
||||
@@ -193,12 +189,13 @@ async def move_shapes(
|
||||
from sqlalchemy import or_
|
||||
|
||||
from grimoire.services import access
|
||||
from grimoire.services.snippets import _path_touches
|
||||
|
||||
old = (from_path or "").strip().strip("/")
|
||||
new = (to_path or "").strip().strip("/")
|
||||
if not old or not new:
|
||||
raise ValueError("from_path and to_path are both required")
|
||||
if _under(old, new) or _under(new, old):
|
||||
if _path_touches(old, new) or _path_touches(new, old):
|
||||
raise ValueError(f"`{old}` and `{new}` overlap; move to a path outside the source")
|
||||
if not await access.can_write_project(user_id, project_id):
|
||||
raise ValueError(f"project {project_id} not found or no write access")
|
||||
@@ -213,10 +210,8 @@ async def move_shapes(
|
||||
# LIKE reads `_` as a wildcard; the prefix test is the real filter.
|
||||
sources = [
|
||||
r for r in (await session.execute(beneath(old))).scalars().all()
|
||||
if _under(r.path, old)
|
||||
if _path_touches(old, r.path)
|
||||
]
|
||||
if not sources:
|
||||
raise ValueError(f"project {project_id} has no shapes under `{old}`")
|
||||
targets = {
|
||||
(r.repo_key, r.path, r.symbol, r.kind): r
|
||||
for r in (await session.execute(beneath(new))).scalars().all()
|
||||
|
||||
@@ -1280,6 +1280,52 @@ def _path_touches(recorded: str, changed: str) -> bool:
|
||||
return changed == recorded or changed.startswith(recorded + "/")
|
||||
|
||||
|
||||
async def move_locations(
|
||||
user_id: int, project_id: int, from_path: str, to_path: str,
|
||||
) -> dict:
|
||||
"""Re-path the recorded locations under ``from_path`` (a file or a
|
||||
directory) to the same place under ``to_path``, on every live snippet of
|
||||
the project the caller can edit.
|
||||
|
||||
The twin of the shape ledger's move: a snippet recorded at a path that
|
||||
moved reads `missing` at its next check, and its canonical stamp no longer
|
||||
covers the shape that moved. Each snippet is rewritten through
|
||||
update_snippet, so its body and indexed fields stay one record. A
|
||||
snippet the caller can only read is left as it is and listed in
|
||||
``read_only``.
|
||||
"""
|
||||
old = (from_path or "").strip().strip("/")
|
||||
new = (to_path or "").strip().strip("/")
|
||||
async with async_session() as session:
|
||||
notes = (
|
||||
await session.execute(
|
||||
select(Note).where(
|
||||
Note.project_id == project_id,
|
||||
Note.note_type == SNIPPET_NOTE_TYPE,
|
||||
Note.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
).scalars().all()
|
||||
moved: list[int] = []
|
||||
read_only: list[int] = []
|
||||
for note in notes:
|
||||
locations = snippet_fields(note).get("locations") or []
|
||||
if not any(_path_touches(old, loc.get("path") or "") for loc in locations):
|
||||
continue
|
||||
repathed = [
|
||||
{**loc, "path": new + loc["path"].strip("/")[len(old):]}
|
||||
if _path_touches(old, loc.get("path") or "") else loc
|
||||
for loc in locations
|
||||
]
|
||||
try:
|
||||
await update_snippet(user_id, int(note.id), locations=repathed)
|
||||
except PermissionError:
|
||||
read_only.append(int(note.id))
|
||||
continue
|
||||
moved.append(int(note.id))
|
||||
return {"moved": moved, "read_only": read_only}
|
||||
|
||||
|
||||
async def invalidate_for_push(
|
||||
repo_key: str,
|
||||
changed: list[str],
|
||||
|
||||
Reference in New Issue
Block a user