refactor(skills): dedupe file-listing, share user-modified predicate, trim diff contract
Cleanup pass on the salvage (behavior-preserving): - diff_bundled_skill now uses the existing _skill_file_list() helper instead of reimplementing the rglob/is_file/relative_to file-set enumeration inline (twice). - Extract _is_tracked_user_modification(origin_hash, user_hash) and use it in BOTH the sync loop and list_user_modified_bundled_skills() so the 'kept user edit' rule can't drift between the two sites. - _read_text_for_diff -> _read_for_diff returns (bytes, text); the binary branch now compares the bytes it already read instead of re-reading both files from disk. - Drop the unused 'user_present' key from diff_bundled_skill's return contract (no consumer or test ever read it). - test_update_modified_notice: drop the brittle '>= 2 sites' count-floor so consolidating the two print paths into a shared helper stays a welcome refactor; keep the per-site 'count notice => discovery hint' invariant (still mutation-tested).
This commit is contained in:
parent
737007e335
commit
f6fac60e66
@ -32,19 +32,22 @@ def test_every_user_modified_notice_points_at_list_modified():
|
|||||||
lines = _source_lines()
|
lines = _source_lines()
|
||||||
count_sites = [i for i, ln in enumerate(lines) if _COUNT_RE.search(ln)]
|
count_sites = [i for i, ln in enumerate(lines) if _COUNT_RE.search(ln)]
|
||||||
|
|
||||||
# There are at least two such notices today; the bug was that only one of
|
# The notice must exist somewhere (guard against it being deleted outright),
|
||||||
# them carried the discovery hint. Assert each is followed (within a small
|
# but we deliberately do NOT assert a fixed *count* of sites: consolidating
|
||||||
# window — the count print + the hint print) by the list-modified pointer.
|
# the duplicated print paths into a shared helper is a welcome refactor and
|
||||||
assert len(count_sites) >= 2, (
|
# must not fail this test. The invariant is per-site, not how many sites.
|
||||||
"expected at least two 'user-modified (kept)' notices in main.py; "
|
assert count_sites, (
|
||||||
f"found {len(count_sites)}"
|
"no 'user-modified (kept)' notice found in main.py — the update "
|
||||||
|
"summary that surfaces kept user edits appears to have been removed"
|
||||||
)
|
)
|
||||||
|
|
||||||
for idx in count_sites:
|
for idx in count_sites:
|
||||||
|
# The count print and its discovery hint sit on adjacent lines; allow a
|
||||||
|
# small window so wording/formatting tweaks don't break the check.
|
||||||
window = "\n".join(lines[idx : idx + 5])
|
window = "\n".join(lines[idx : idx + 5])
|
||||||
assert _HINT_RE.search(window), (
|
assert _HINT_RE.search(window), (
|
||||||
"a 'user-modified (kept)' notice near line "
|
"a 'user-modified (kept)' notice near line "
|
||||||
f"{idx + 1} of main.py does not point users at "
|
f"{idx + 1} of main.py does not point users at "
|
||||||
"`hermes skills list-modified` within the following lines — the "
|
"`hermes skills list-modified` within the following lines — the "
|
||||||
"two update paths have drifted apart again:\n" + window
|
"update paths have drifted apart again:\n" + window
|
||||||
)
|
)
|
||||||
|
|||||||
@ -575,7 +575,7 @@ def sync_skills(quiet: bool = False) -> dict:
|
|||||||
skipped += 1
|
skipped += 1
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if user_hash != origin_hash:
|
if _is_tracked_user_modification(origin_hash, user_hash):
|
||||||
# User modified this skill — don't overwrite their changes
|
# User modified this skill — don't overwrite their changes
|
||||||
user_modified.append(skill_name)
|
user_modified.append(skill_name)
|
||||||
if not quiet:
|
if not quiet:
|
||||||
@ -785,6 +785,18 @@ def reset_bundled_skill(name: str, restore: bool = False) -> dict:
|
|||||||
return {"ok": True, "action": action, "message": message, "synced": synced}
|
return {"ok": True, "action": action, "message": message, "synced": synced}
|
||||||
|
|
||||||
|
|
||||||
|
def _is_tracked_user_modification(origin_hash: str, user_hash: str) -> bool:
|
||||||
|
"""Whether an on-disk skill counts as a user modification ``hermes update`` keeps.
|
||||||
|
|
||||||
|
Shared by the sync loop (which decides what to skip) and
|
||||||
|
``list_user_modified_bundled_skills`` (which surfaces the names) so the two
|
||||||
|
can never drift. A skill is a tracked modification only when it has a
|
||||||
|
recorded origin hash (an un-baselined / v1 entry with an empty hash is not)
|
||||||
|
and its current content hash differs from that origin.
|
||||||
|
"""
|
||||||
|
return bool(origin_hash) and user_hash != origin_hash
|
||||||
|
|
||||||
|
|
||||||
def list_user_modified_bundled_skills() -> List[dict]:
|
def list_user_modified_bundled_skills() -> List[dict]:
|
||||||
"""Return the bundled skills that ``hermes update`` keeps because the user
|
"""Return the bundled skills that ``hermes update`` keeps because the user
|
||||||
edited them locally.
|
edited them locally.
|
||||||
@ -806,7 +818,7 @@ def list_user_modified_bundled_skills() -> List[dict]:
|
|||||||
bundled_dir = _get_bundled_dir()
|
bundled_dir = _get_bundled_dir()
|
||||||
modified: List[dict] = []
|
modified: List[dict] = []
|
||||||
for skill_name, skill_dir in _discover_bundled_skills(bundled_dir):
|
for skill_name, skill_dir in _discover_bundled_skills(bundled_dir):
|
||||||
origin_hash = manifest.get(skill_name)
|
origin_hash = manifest.get(skill_name, "")
|
||||||
# No entry, or a v1 entry not yet baselined (empty hash): not a tracked
|
# No entry, or a v1 entry not yet baselined (empty hash): not a tracked
|
||||||
# modification — the next sync handles it.
|
# modification — the next sync handles it.
|
||||||
if not origin_hash:
|
if not origin_hash:
|
||||||
@ -814,7 +826,7 @@ def list_user_modified_bundled_skills() -> List[dict]:
|
|||||||
dest = _compute_relative_dest(skill_dir, bundled_dir)
|
dest = _compute_relative_dest(skill_dir, bundled_dir)
|
||||||
if not dest.exists():
|
if not dest.exists():
|
||||||
continue
|
continue
|
||||||
if _dir_hash(dest) != origin_hash:
|
if _is_tracked_user_modification(origin_hash, _dir_hash(dest)):
|
||||||
modified.append(
|
modified.append(
|
||||||
{"name": skill_name, "dest": dest, "bundled_src": skill_dir}
|
{"name": skill_name, "dest": dest, "bundled_src": skill_dir}
|
||||||
)
|
)
|
||||||
@ -822,18 +834,23 @@ def list_user_modified_bundled_skills() -> List[dict]:
|
|||||||
return modified
|
return modified
|
||||||
|
|
||||||
|
|
||||||
def _read_text_for_diff(path: Path) -> Optional[str]:
|
def _read_for_diff(path: Path) -> Tuple[Optional[bytes], Optional[str]]:
|
||||||
"""Return file text for diffing, or ``None`` if the file is binary/unreadable."""
|
"""Read a file once for diffing.
|
||||||
|
|
||||||
|
Returns ``(raw_bytes, text)`` where ``text`` is ``None`` if the file is
|
||||||
|
binary; ``(None, None)`` if it could not be read. Returning the raw bytes
|
||||||
|
lets the caller compare binary files without re-reading them.
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
data = path.read_bytes()
|
data = path.read_bytes()
|
||||||
except OSError:
|
except OSError:
|
||||||
return None
|
return None, None
|
||||||
if b"\x00" in data:
|
if b"\x00" in data:
|
||||||
return None
|
return data, None
|
||||||
try:
|
try:
|
||||||
return data.decode("utf-8")
|
return data, data.decode("utf-8")
|
||||||
except UnicodeDecodeError:
|
except UnicodeDecodeError:
|
||||||
return None
|
return data, None
|
||||||
|
|
||||||
|
|
||||||
def diff_bundled_skill(name: str) -> dict:
|
def diff_bundled_skill(name: str) -> dict:
|
||||||
@ -844,7 +861,7 @@ def diff_bundled_skill(name: str) -> dict:
|
|||||||
|
|
||||||
Returns a dict:
|
Returns a dict:
|
||||||
``ok`` (bool), ``name`` (str), ``found`` (bool — bundled source exists),
|
``ok`` (bool), ``name`` (str), ``found`` (bool — bundled source exists),
|
||||||
``user_present`` (bool), ``modified`` (bool), ``message`` (str),
|
``modified`` (bool), ``message`` (str),
|
||||||
``diffs``: list of ``{"path": str, "status": str, "diff": str}`` where
|
``diffs``: list of ``{"path": str, "status": str, "diff": str}`` where
|
||||||
status is one of ``modified`` / ``added`` (only in user copy) /
|
status is one of ``modified`` / ``added`` (only in user copy) /
|
||||||
``removed`` (only in bundled) / ``binary``.
|
``removed`` (only in bundled) / ``binary``.
|
||||||
@ -859,7 +876,6 @@ def diff_bundled_skill(name: str) -> dict:
|
|||||||
"ok": False,
|
"ok": False,
|
||||||
"name": name,
|
"name": name,
|
||||||
"found": False,
|
"found": False,
|
||||||
"user_present": False,
|
|
||||||
"modified": False,
|
"modified": False,
|
||||||
"diffs": [],
|
"diffs": [],
|
||||||
"message": (
|
"message": (
|
||||||
@ -873,32 +889,30 @@ def diff_bundled_skill(name: str) -> dict:
|
|||||||
"ok": False,
|
"ok": False,
|
||||||
"name": name,
|
"name": name,
|
||||||
"found": True,
|
"found": True,
|
||||||
"user_present": False,
|
|
||||||
"modified": False,
|
"modified": False,
|
||||||
"diffs": [],
|
"diffs": [],
|
||||||
"message": f"No local copy of '{name}' found at {dest}.",
|
"message": f"No local copy of '{name}' found at {dest}.",
|
||||||
}
|
}
|
||||||
|
|
||||||
user_files = {
|
user_files = set(_skill_file_list(dest))
|
||||||
p.relative_to(dest).as_posix() for p in dest.rglob("*") if p.is_file()
|
stock_files = set(_skill_file_list(bundled_src))
|
||||||
}
|
|
||||||
stock_files = {
|
|
||||||
p.relative_to(bundled_src).as_posix()
|
|
||||||
for p in bundled_src.rglob("*")
|
|
||||||
if p.is_file()
|
|
||||||
}
|
|
||||||
|
|
||||||
diffs: List[dict] = []
|
diffs: List[dict] = []
|
||||||
for rel in sorted(user_files | stock_files):
|
for rel in sorted(user_files | stock_files):
|
||||||
in_user = rel in user_files
|
in_user = rel in user_files
|
||||||
in_stock = rel in stock_files
|
in_stock = rel in stock_files
|
||||||
user_text = _read_text_for_diff(dest / rel) if in_user else None
|
user_bytes, user_text = (
|
||||||
stock_text = _read_text_for_diff(bundled_src / rel) if in_stock else None
|
_read_for_diff(dest / rel) if in_user else (None, None)
|
||||||
|
)
|
||||||
|
stock_bytes, stock_text = (
|
||||||
|
_read_for_diff(bundled_src / rel) if in_stock else (None, None)
|
||||||
|
)
|
||||||
|
|
||||||
if in_user and in_stock:
|
if in_user and in_stock:
|
||||||
if user_text is None or stock_text is None:
|
if user_text is None or stock_text is None:
|
||||||
# At least one side is binary — report only if bytes differ.
|
# At least one side is binary — report only if bytes differ
|
||||||
if (dest / rel).read_bytes() != (bundled_src / rel).read_bytes():
|
# (reuse the bytes already read above, no second read).
|
||||||
|
if user_bytes != stock_bytes:
|
||||||
diffs.append(
|
diffs.append(
|
||||||
{"path": rel, "status": "binary", "diff": "<binary file differs>"}
|
{"path": rel, "status": "binary", "diff": "<binary file differs>"}
|
||||||
)
|
)
|
||||||
@ -928,7 +942,6 @@ def diff_bundled_skill(name: str) -> dict:
|
|||||||
"ok": True,
|
"ok": True,
|
||||||
"name": name,
|
"name": name,
|
||||||
"found": True,
|
"found": True,
|
||||||
"user_present": True,
|
|
||||||
"modified": modified,
|
"modified": modified,
|
||||||
"diffs": diffs,
|
"diffs": diffs,
|
||||||
"message": (
|
"message": (
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user