diff-based mod removal via --previous-manifest

Adds removal-detection: when --previous-manifest <path> is given,
the converter diffs the previous publish against current packwiz state
and emits modify[].type=remove entries for mods/resourcepacks/etc that
disappeared, using simple-mod-sync's on-disk naming convention as the
regex pattern.

Reverse-engineered from upstream source:
- simple-mod-sync writes <sanitized_name>-<sanitized_version>.<ext>
- StringUtils.sanitize strips [^a-zA-Z0-9.\-_]
- GetOlderVersion() finds files starting with <name>- and auto-deletes
  on version bumps. So version upgrades need no converter handling;
  only full removals do.

8 new tests including end-to-end CLI verification with a synthetic
previous manifest. 23/23 pass.
This commit is contained in:
2026-06-02 11:50:51 +02:00
parent 8651b076d3
commit 438b1f7c65
3 changed files with 222 additions and 3 deletions
+98 -2
View File
@@ -85,6 +85,40 @@ _DIR_TO_TYPE = {
"shaderpacks": "shader",
}
# simple-mod-sync writes downloaded files as <sanitized_name>-<sanitized_version>.<ext>
# under <type>'s target directory. See StringUtils.sanitize() in the mod source:
# replaceAll("[^a-zA-Z0-9.\\-_]", "")
_SANITIZE_RE = re.compile(r"[^a-zA-Z0-9.\-_]")
_TYPE_TO_DIR_AND_EXT = {
"mod": ("mods", "jar"),
"resourcepack": ("resourcepacks", "zip"),
"shader": ("shaderpacks", "zip"),
"datapack": ("datapacks", "zip"),
}
def _sanitize(s: str) -> str:
return _SANITIZE_RE.sub("", s)
def _remove_pattern_for(entry: dict[str, Any]) -> str | None:
"""Build a regex that matches any on-disk file simple-mod-sync would have
written for this content entry (current or older versions).
Mirrors simple-mod-sync's `GetProjectName(): name + "-"` prefix search
in `DirUtils.DirContains` (which uses startswith)."""
ctype = entry.get("type")
spec = _TYPE_TO_DIR_AND_EXT.get(ctype)
if spec is None:
return None
target_dir, ext = spec
safe_name = _sanitize(entry.get("name", ""))
if not safe_name:
return None
# Anchor whole path, match any version suffix + the extension.
return rf"^{re.escape(target_dir)}/{re.escape(safe_name)}-.*\.{ext}$"
def _content_type_for(rel_path: Path) -> str | None:
"""Map mods/foo.pw.toml -> "mod", resourcepacks/foo.pw.toml -> "resourcepack" etc.
@@ -124,6 +158,39 @@ class ConvertResult:
skipped_no_url: list[str]
skipped_unknown_type: list[str]
non_mod_files: list[Path]
removed_entries: list[dict[str, Any]] = None # populated when --previous-manifest given
def __post_init__(self) -> None:
if self.removed_entries is None:
self.removed_entries = []
def diff_removals(
previous_sync: list[dict[str, Any]],
current_sync: list[dict[str, Any]],
) -> list[dict[str, Any]]:
"""Return simple-mod-sync `modify` entries that delete files for content
present in `previous_sync` but absent from `current_sync`.
Identity is `(name, type)` — covers the common case (rename a mod →
treated as remove+add, which is the right behavior because the old
file's prefix no longer matches anything in the sync list).
"""
cur_keys = {(e.get("name"), e.get("type")) for e in current_sync}
removals: list[dict[str, Any]] = []
for prev in previous_sync:
key = (prev.get("name"), prev.get("type"))
if key in cur_keys:
continue
pattern = _remove_pattern_for(prev)
if pattern is None:
continue
removals.append({
"type": "remove",
"pattern": pattern,
"path": ".",
})
return removals
def convert(
@@ -131,6 +198,7 @@ def convert(
*,
include_client_only: bool = True,
include_both: bool = True,
previous_manifest: dict[str, Any] | None = None,
) -> ConvertResult:
pack = _load_pack(pack_root)
index = _load_index(pack_root)
@@ -166,7 +234,7 @@ def convert(
sync.append(_make_entry(meta, content_type))
manifest = {
manifest: dict[str, Any] = {
"sync_version": 3,
"_generator": {
"tool": "packwiz-to-sms",
@@ -176,12 +244,21 @@ def convert(
"sync": sync,
}
removed_entries: list[dict[str, Any]] = []
if previous_manifest is not None:
prev_sync = previous_manifest.get("sync", [])
modify = diff_removals(prev_sync, sync)
if modify:
manifest["modify"] = modify
removed_entries = modify
return ConvertResult(
manifest=manifest,
skipped_server_only=skipped_server_only,
skipped_no_url=skipped_no_url,
skipped_unknown_type=skipped_unknown_type,
non_mod_files=non_mod_files,
removed_entries=removed_entries,
)
@@ -231,6 +308,12 @@ def _build_arg_parser() -> argparse.ArgumentParser:
"-o", "--output", type=Path, default=None,
help="Write manifest to this file (default stdout).",
)
p.add_argument(
"--previous-manifest", type=Path, default=None, metavar="JSON_PATH",
help="Path to the previously-published manifest. Names that disappeared "
"between previous and current are emitted as modify[].type=remove entries "
"matching simple-mod-sync's on-disk naming, so clients auto-clean removed mods.",
)
p.add_argument(
"--bundle-non-mods", type=Path, default=None, metavar="ZIP_PATH",
help="Zip all non-mod files into this zip and add a 'packed' entry pointing at --bundle-url.",
@@ -254,7 +337,18 @@ def main(argv: list[str] | None = None) -> int:
print(f"error: {args.pack_root} does not contain pack.toml", file=sys.stderr)
return 2
result = convert(args.pack_root)
previous_manifest = None
if args.previous_manifest:
if not args.previous_manifest.is_file():
print(
f"warning: --previous-manifest {args.previous_manifest} does not exist, "
f"skipping removal detection",
file=sys.stderr,
)
else:
previous_manifest = json.loads(args.previous_manifest.read_text(encoding="utf-8"))
result = convert(args.pack_root, previous_manifest=previous_manifest)
if args.bundle_non_mods:
if not args.bundle_url:
@@ -277,6 +371,8 @@ def main(argv: list[str] | None = None) -> int:
f"(config/, options.txt, ...) — pass --bundle-non-mods to ship them",
file=sys.stderr,
)
for r in result.removed_entries:
print(f"remove: {r['pattern']}", file=sys.stderr)
payload = json.dumps(result.manifest, indent=2) + "\n"
if args.output: