feat(6243): a directory moved in the tree keeps its shape judgments
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Failing after 15s
CI & Build / TypeScript typecheck (push) Successful in 55s
CI & Build / integration (push) Successful in 1m15s
CI & Build / Python tests (push) Successful in 2m9s
CI & Build / Build & push image (push) Successful in 30s

Ledger rows are keyed by path, so after a directory moves the next sync
marks every judged row vanished and lists the same shapes again, unjudged.
move_shapes(project_id, from_path, to_path) re-keys the rows under one path
to the other, in every repo of the project, keeping each row's judgment,
uses edges and history and adding a `moved` event that records the path it
left. A row already at the target is replaced when nobody judged it; a
judged one stands, and the old row is reported in `kept`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-22 15:03:03 -04:00
co-authored by Claude Opus 5.5
parent 5ad1df8e0f
commit 47b52197aa
8 changed files with 259 additions and 9 deletions
+79
View File
@@ -170,6 +170,85 @@ 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:
"""Carry the ledger rows under ``from_path`` (a file or a directory) to
the same place under ``to_path``, in every repo of the project.
Rows are keyed by path, so a directory moved in the tree would otherwise
vanish every judged row at the next sync and start the same shapes again,
unclassified, under the new path. A moved row is the same row — its id,
judgment, uses edges and history ride along — and a `moved` event
records the path it left. A row already at the target (the sync ran
first, or a provisional judgment landed there) is replaced when nobody
judged it; a judged one stands, and the old row is left where it is and
reported in ``kept``. Bodies are not compared here: the next sync does
that as always, and asks for a recheck where a judged body changed.
"""
from sqlalchemy import or_
from grimoire.services import access
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):
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")
now = datetime.now(timezone.utc)
async with async_session() as session:
def beneath(prefix: str):
return select(CodeShape).where(
CodeShape.project_id == project_id,
or_(CodeShape.path == prefix, CodeShape.path.like(prefix + "/%")),
)
# 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 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()
}
moved, replaced, kept = 0, 0, []
for row in sources:
dest = new + row.path[len(old):]
there = targets.get((row.repo_key, dest, row.symbol, row.kind))
if there is not None:
if there.status not in _MECHANICAL_TODO:
kept.append({"path": row.path, "symbol": row.symbol, "kind": row.kind})
continue
# The target is the live sighting when the sync got there
# first; the moved row takes over its presence.
row.vanished_at = there.vanished_at
row.last_seen_commit = there.last_seen_commit or row.last_seen_commit
await session.delete(there)
await session.flush()
replaced += 1
session.add(CodeShapeEvent(
shape_id=row.id, project_id=project_id,
path=row.path, symbol=row.symbol, kind=row.kind,
event="moved", status=row.status, snippet_id=row.snippet_id,
classified_by=row.classified_by, reason=row.reason,
commit=row.last_seen_commit or "", at=now,
))
row.path = dest
moved += 1
await session.commit()
return {"moved": moved, "replaced": replaced, "kept": kept}
def _event(row: CodeShape, event: str, at: datetime, *, commit: str = "") -> CodeShapeEvent:
"""A history row for a state change on ``row`` — status/snippet/by/reason
are the row's CURRENT values, which for `classified` is the judgment