ReMe/reme/steps/file_io/move.py
jinliyl a9ec334adc
feat: simplify wikilink semantics and support line anchors (#412)
* feat: simplify local links and support line anchors

* fix: align line anchor tests with CI lint

* fix: preserve local links across file moves

* fix: encode markdown paths when rewriting links

* refactor(read): keep explicit line range parameters

* fix: simplify legacy link predicate compatibility

* docs: align local link behavior with implementation

* fix: skip unsupported markdown destination escapes

* fix: normalize workspace link paths across platforms

* fix: bound markdown link scanning

* fix: keep local link processing linear

* docs: clarify permissive markdown link parsing

* fix: handle local link processing failures

* refactor: limit file links to wikilink syntax

* docs: align wikilink contract with implementation

* fix: normalize dream and neighbor paths on Windows

* fix: resolve workspace path for neighbor expansion
2026-08-05 11:47:50 +08:00

148 lines
7 KiB
Python

"""``file_move`` — relocate / rename a file within the workspace by copy → retarget → delete.
Three-step ordering keeps workspace_dir referentially consistent at every
intermediate point — no window in which inbound ``[[src_path]]`` wikilinks
dangle:
1. ``shutil.copyfile(src_path, dst_path)``. Both files now exist on disk;
inbound ``[[src_path]]`` still resolves (to the original location).
2. ``retarget_links(src_path, dst_path)``. Rewrites every inbound
``[[src_path]]`` → ``[[dst_path]]`` across the workspace. Both files
exist throughout, so rewrites can land in any order without
breaking resolution.
3. ``src_abs.unlink()``. References now point at ``dst_path``; the
original is an orphan and is safely removed.
If retargeting fails (raises or returns an error payload), step 3 is
skipped — both files remain on disk so the caller can diagnose and
retry; workspace_dir stays consistent (references still resolve to the
original). The ``src_removed`` boolean in the payload distinguishes
the two cases.
``src_path`` must resolve inside workspace_dir as a path relative to the
workspace; ``dst_path`` must be relative to the workspace with a directory
component (same rule as ``file_upload``). For cross-realm transfer
(workspace_dir ↔ local fs) use ``file_upload`` / ``file_download``.
Opt out via ``retarget=False`` for the rare case where you intentionally
want to leave references stale (e.g. moving aside before delete) — the
original is still removed in that case (move semantics, not copy).
"""
import shutil
from pathlib import Path
from ._path import resolve_path
from ..base_step import BaseStep
from ...components import R
from ...utils.wikilink_handler import WikilinkHandler
@R.register("move_step")
class MoveStep(BaseStep):
"""Move ``src_path`` to ``dst_path`` within the workspace (copy → retarget → unlink)."""
async def execute(self):
assert self.context is not None
src_path: str = self.context.get("src_path", "")
dst_path: str = self.context.get("dst_path", "")
overwrite: bool = bool(self.context.get("overwrite", False))
retarget: bool = bool(self.context.get("retarget", True))
assert src_path and dst_path, "src_path and dst_path are required"
payload = await self._move(src_path, dst_path, overwrite, retarget)
if "error" in payload:
self.context.response.success = False
self.context.response.answer = f"Error: {payload['error']}"
self.logger.info(f"[{self.name}] move failed src={src_path} dst={dst_path} error={payload['error']!r}")
else:
self.context.response.success = True
self.context.response.answer = f"Moved {src_path}{dst_path}"
retarget_info = payload.get("retarget") or {}
self.logger.info(
f"[{self.name}] moved src={src_path} dst={dst_path} src_removed={payload.get('src_removed')} "
f"retarget_files={retarget_info.get('files_touched', 0) if isinstance(retarget_info, dict) else '-'} "
f"retarget_links={retarget_info.get('links_changed', 0) if isinstance(retarget_info, dict) else '-'}",
)
self.context.response.metadata.update(payload)
# pylint: disable=too-many-return-statements
async def _move(self, src_path: str, dst_path: str, overwrite: bool, retarget: bool) -> dict:
workspace_dir = Path(self.file_store.workspace_path or ".").resolve()
src_abs, src_err = resolve_path(workspace_dir, src_path) if src_path else (None, "src_path is required")
dst_abs, dst_err = resolve_path(workspace_dir, dst_path) if dst_path else (None, "dst_path is required")
if src_err:
return {"src_path": src_path, "error": src_err}
if dst_err:
return {"dst_path": dst_path, "error": dst_err}
precheck_error = _precheck_move(src_path, dst_path, src_abs, dst_abs, overwrite)
if precheck_error:
return precheck_error
assert src_abs is not None and dst_abs is not None # narrowed by precheck
dst_abs.parent.mkdir(parents=True, exist_ok=True)
# Step 1 — copy. Wikilinks use workspace-relative paths, so outgoing
# targets do not change when their containing document moves.
shutil.copyfile(str(src_abs), str(dst_abs))
payload: dict = {"src_path": src_path, "dst_path": dst_path, "size": dst_abs.stat().st_size}
# Step 2 — retarget. workspace_dir stays consistent throughout: refs still
# at [[src_path]] resolve to the original; refs already rewritten to
# [[dst_path]] resolve to the new location. On error, bail before
# unlinking so the caller can retry; workspace_dir is still consistent.
if retarget:
try:
report = await WikilinkHandler.retarget_links(self.file_store, src=src_path, dst=dst_path)
except Exception as exc:
error = f"retarget raised: {exc!r}"
payload["error"] = error
payload["retarget"] = {"error": error}
payload["src_removed"] = False
return payload
if "error" in report:
payload["error"] = report["error"]
payload["retarget"] = report
payload["src_removed"] = False
return payload
payload["retarget"] = {
"files_touched": report.get("files_touched", 0),
"links_changed": report.get("links_changed", 0),
"by_file": report.get("by_file", []),
"ambiguous": report.get("ambiguous", []),
}
else:
payload["retarget"] = None
# Step 3 — unlink the original. Refs all point at dst_path now; the
# original is an orphan. If unlink fails, workspace_dir is still
# consistent (refs resolve to dst_path); the original just lingers
# as an orphan that the caller can clean up.
try:
src_abs.unlink()
payload["src_removed"] = True
except Exception as exc:
payload["src_removed"] = False
payload["src_remove_error"] = f"unlink raised: {exc!r}"
return payload
def _precheck_move(
src_path: str,
dst_path: str,
src_abs: Path | None,
dst_abs: Path | None,
overwrite: bool,
) -> dict | None:
"""Validate inputs for ``_move``; return an error payload or ``None`` when OK."""
if src_abs is None or not src_abs.is_file():
return {"src_path": src_path, "error": "not found"}
if dst_abs is None or "/" not in dst_path:
return {
"dst_path": dst_path,
"error": "dst_path must be relative to the workspace with a directory component",
}
if dst_abs == src_abs:
return {"src_path": src_path, "dst_path": dst_path, "error": "src_path and dst_path are the same"}
if dst_abs.exists() and not overwrite:
return {"dst_path": dst_path, "error": "destination exists; pass overwrite=True"}
return None