Skip to content

Commit 6d627d4

Browse files
committed
Symlink custom deployed folders
1 parent eeabc30 commit 6d627d4

3 files changed

Lines changed: 106 additions & 3 deletions

File tree

Changelog.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
- Tweaked ue5 conflict detection to stop showing false conflicts
44
- Fixed a bug where a renamed separator would still behave as if it had it's old name
55
- Added Street Fighter 6
6+
- The Data tab filetree no longer auto collapses when enabling, disabling or moving a mod
7+
- Added a filter option to the ini files tab, The ini files tab also shows more file types
68

79
- v1.3.4
810
- Added a Skygen and plugin audit wizard

src/Utils/deploy_standard.py

Lines changed: 103 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,15 @@ def deploy_filemap(
186186
# {custom_deploy_dir_str: {top_level_folder_name, ...}} — populated as we
187187
# build tasks, consumed by the folder-replace pass below.
188188
_custom_top_roots: dict[str, set[str]] = {}
189+
# {(custom_deploy_dir_str, top_folder_lower): owner_mod_name or None} —
190+
# records which mod owns each top-level folder. None means multiple mods
191+
# contribute to it (so we fall back to per-file deploy instead of a
192+
# single directory symlink). Folders ending up with exactly one owner are
193+
# candidates for the directory-symlink optimization.
194+
_top_folder_owner: dict[tuple[str, str], str | None] = {}
195+
# {(custom_deploy_dir_str, top_folder_lower): one (src_str, rel_str) pair}
196+
# — used to derive the source-side top-level folder path for the symlink.
197+
_top_folder_sample: dict[tuple[str, str], tuple[str, str]] = {}
189198
for line in _tab_lines:
190199
rel_str, mod_name = line.split("\t", 1)
191200
# Guard against path traversal in filemap entries.
@@ -244,7 +253,15 @@ def deploy_filemap(
244253
# their top-level folders are merged with the target instead of
245254
# wholesale-replaced; per-file backup-and-replace still applies.
246255
if is_custom_task and "/" in rel_str and mod_name not in _per_merge:
247-
_custom_top_roots.setdefault(_eff_s, set()).add(rel_str.split("/", 1)[0])
256+
_top = rel_str.split("/", 1)[0]
257+
_custom_top_roots.setdefault(_eff_s, set()).add(_top)
258+
_key = (_eff_s, _top.lower())
259+
_existing_owner = _top_folder_owner.get(_key, "__unset__")
260+
if _existing_owner == "__unset__":
261+
_top_folder_owner[_key] = mod_name
262+
_top_folder_sample[_key] = (src_str, rel_str)
263+
elif _existing_owner != mod_name:
264+
_top_folder_owner[_key] = None # multi-owner → no dir-symlink
248265

249266
if progress_fn is not None and line_idx % 500 == 0:
250267
progress_fn(line_idx, total_lines)
@@ -309,6 +326,88 @@ def deploy_filemap(
309326
_resolved_dir_cache.pop(_existing_dir_str.lower(), None)
310327
_dir_listing_cache.pop(os.path.dirname(_existing_dir_str), None)
311328

329+
# Directory-symlink pass: for every single-owner top-level folder we just
330+
# replaced above, drop a directory symlink <dest>/<top> → <staging>/<mod>/<src_top>.
331+
# New files written by the game land directly in the mod's staging dir
332+
# (no manual sync on restore needed). Tasks that fall under one of these
333+
# symlinked folders are excluded from the per-file deploy below.
334+
_dir_symlink_log: list[str] = []
335+
_skipped_task_prefixes: set[str] = set()
336+
for (_eff_dir_s, _top_lower), _owner in _top_folder_owner.items():
337+
if _owner is None:
338+
continue
339+
_sample = _top_folder_sample.get((_eff_dir_s, _top_lower))
340+
if _sample is None:
341+
continue
342+
_sample_src, _sample_rel = _sample
343+
# Derive the source-side folder path: rel_str is e.g. "Saves/foo.ess"
344+
# and src_str ends in "<staging>/<mod>/<resolved>/Saves/foo.ess" (the
345+
# resolved part may include strip_prefix folders). Walk parents of
346+
# src_str up by the number of "/" components in rel_str minus one to
347+
# land on the source-side top-level folder.
348+
_rel_depth = _sample_rel.count("/") # files-deep below the top folder
349+
_src_top = _sample_src
350+
for _ in range(_rel_depth):
351+
_src_top = os.path.dirname(_src_top)
352+
if not os.path.isdir(_src_top):
353+
# Couldn't resolve a real source directory — fall back to per-file.
354+
continue
355+
# Destination path uses the same case-resolved form as per-file deploy.
356+
_dst_top = _resolve_root_path_str(
357+
_eff_dir_s, _sample_rel.split("/", 1)[0], _dir_listing_cache,
358+
resolved_dir_cache=_resolved_dir_cache,
359+
)
360+
# The wholesale-replace pass above moved any vanilla folder away, so
361+
# the dest path should not exist; create the parent dir, then symlink.
362+
try:
363+
os.makedirs(os.path.dirname(_dst_top), exist_ok=True)
364+
# Defensive: drop a leftover empty dir or stale symlink at the spot
365+
try:
366+
_existing_st = os.lstat(_dst_top)
367+
if _stat_module.S_ISLNK(_existing_st.st_mode):
368+
os.unlink(_dst_top)
369+
elif _stat_module.S_ISDIR(_existing_st.st_mode):
370+
try:
371+
os.rmdir(_dst_top)
372+
except OSError:
373+
# Non-empty — bail; per-file deploy will handle it.
374+
continue
375+
except OSError:
376+
pass
377+
os.symlink(_src_top, _dst_top)
378+
_dir_symlink_log.append(_dst_top)
379+
_skipped_task_prefixes.add(_dst_top.rstrip("/") + "/")
380+
_log(f" Symlinked folder {os.path.basename(_dst_top)}/ → {_src_top}")
381+
except OSError as exc:
382+
_log(f" WARN: could not symlink folder {_dst_top}: {exc}")
383+
384+
# Filter out tasks whose destination falls under a directory-symlinked
385+
# folder — they're already covered by the symlink.
386+
if _skipped_task_prefixes:
387+
def _under_symlinked(dst: str) -> bool:
388+
for _pfx in _skipped_task_prefixes:
389+
if dst.startswith(_pfx):
390+
return True
391+
return False
392+
before_count = len(tasks)
393+
tasks = [t for t in tasks if not _under_symlinked(t[1])]
394+
# Mark every skipped rel_lower as "placed" so deploy_core() doesn't
395+
# try to provide a vanilla fallback for these paths.
396+
for _line in _tab_lines:
397+
if "\t" not in _line:
398+
continue
399+
_rs, _mn = _line.rstrip("\n").split("\t", 1)
400+
_dst_check = _resolve_root_path_str(
401+
str(_per_deploy.get(_mn, deploy_dir)) if _mn in _per_deploy else _deploy_dir_str,
402+
_rs, _dir_listing_cache, resolved_dir_cache=_resolved_dir_cache,
403+
)
404+
if _under_symlinked(_dst_check):
405+
placed_lower.add(_rs.lower())
406+
print(f" [TIMER] deploy_filemap — directory-symlink pass: skipped "
407+
f"{before_count - len(tasks)} per-file task(s) under "
408+
f"{len(_skipped_task_prefixes)} folder symlink(s).")
409+
total = len(tasks)
410+
312411
# Pre-create all destination directories up front (single-threaded) to
313412
# avoid mkdir races inside the thread pool.
314413
with _timer("deploy_filemap — mkdir"):
@@ -370,12 +469,14 @@ def _do_transfer(item: tuple[str, str, str, bool, bool, "LinkMode | None"]) -> t
370469
print(f" [TIMER] deploy_filemap — transfer {total} files: {_time.perf_counter() - _t_transfer:.3f}s")
371470

372471
# Write a log of files placed in custom locations so cleanup knows what to
373-
# remove. Each line is the absolute path of a deployed file.
472+
# remove. Each line is the absolute path of a deployed file (or a
473+
# directory symlink we created via the dir-symlink pass).
374474
custom_deployed = [
375475
dst
376476
for _src, dst, rel_lower, is_custom, _use_sym, _ov in tasks
377477
if is_custom and rel_lower in placed_lower
378478
]
479+
custom_deployed.extend(_dir_symlink_log)
379480
try:
380481
if custom_deployed:
381482
_custom_log_path.write_text("\n".join(custom_deployed), encoding="utf-8")

src/version.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
__version__ = "1.3.5-beta.3"
1+
__version__ = "1.3.5-beta.4"

0 commit comments

Comments
 (0)