"""Parallel work tracks for TheDescentUE without git. The main project (D:\\HADAL - The Descent\\TheDescentUE) is the integration tree. Each Coder works in its own copy so builds never interfere, and the Administrator merges the copy back with a three-way merge. python tracks.py fork copy main -> .wt//TheDescentUE and snapshot -> .wt//base python tracks.py status what the track changed, and whether main changed the same files python tracks.py merge [--dry] apply the track's changes to main (git merge-file for text conflicts) Heavy, regenerated folders (Intermediate, Binaries, Saved, DerivedDataCache) are never copied or merged. """ import filecmp import hashlib import shutil import subprocess import sys from pathlib import Path ROOT = Path(r"D:\HADAL - The Descent") MAIN = ROOT / "TheDescentUE" WT = ROOT / ".wt" SKIP_DIRS = {"Intermediate", "Binaries", "Saved", "DerivedDataCache", ".vs", "__pycache__"} TEXT_EXT = {".h", ".cpp", ".cs", ".ini", ".py", ".ps1", ".bat", ".md", ".json", ".inl", ".uproject", ".txt", ".xml", ".usf", ".ush", ".csv", ".gitignore", ".cfg", ".toml", ".yml", ".yaml"} def files(tree: Path) -> dict: """Relative path -> absolute path for every tracked file under tree.""" out = {} for p in tree.rglob("*"): if not p.is_file(): continue rel = p.relative_to(tree) if rel.parts[0] in SKIP_DIRS or "__pycache__" in rel.parts: continue out[rel.as_posix()] = p return out def digest(p): if p is None or not p.exists(): return None h = hashlib.sha1() with open(p, "rb") as f: for chunk in iter(lambda: f.read(1 << 20), b""): h.update(chunk) return h.hexdigest() def is_text(rel: str) -> bool: p = Path(rel) return p.suffix.lower() in TEXT_EXT or p.name in {".gitignore"} def ignore(_dir, names): return [n for n in names if n in SKIP_DIRS] def fork(name: str): dst = WT / name if dst.exists(): sys.exit(f"track '{name}' already exists at {dst}") shutil.copytree(MAIN, dst / "TheDescentUE", ignore=ignore) shutil.copytree(MAIN, dst / "base", ignore=ignore) # The import script keeps its "already imported" stamps here; without them a fork re-imports everything. stamps = MAIN / "Saved" / "ImportState" if stamps.exists(): shutil.copytree(stamps, dst / "TheDescentUE" / "Saved" / "ImportState") n = len(files(dst / "base")) print(f"FORK OK {name}: {n} files -> {dst / 'TheDescentUE'}") def classify(name: str): track, base = WT / name / "TheDescentUE", WT / name / "base" if not track.exists() or not base.exists(): sys.exit(f"no such track: {name}") ft, fb, fm = files(track), files(base), files(MAIN) rows = [] for rel in sorted(set(ft) | set(fb)): t, b, m = digest(ft.get(rel)), digest(fb.get(rel)), digest(fm.get(rel)) if t == b: continue # the track did not touch it kind = "add" if b is None else ("del" if t is None else "mod") if m == t: action = "same" # main already has the identical change elif m == b: action = "apply" # only the track changed it else: action = "merge" # both changed it rows.append((rel, kind, action, ft.get(rel), fb.get(rel), MAIN / rel)) return rows def status(name: str): rows = classify(name) for rel, kind, action, *_ in rows: print(f"{kind:3} {action:5} {rel}") n = {a: sum(1 for r in rows if r[2] == a) for a in ("apply", "merge", "same")} print(f"STATUS {name}: {len(rows)} changed apply={n['apply']} merge={n['merge']} same={n['same']}") def merge(name: str, dry: bool): rows = classify(name) applied, merged, conflicts = 0, 0, [] for rel, kind, action, t, b, m in rows: if action == "same": continue if action == "apply": if not dry: if kind == "del": m.unlink(missing_ok=True) else: m.parent.mkdir(parents=True, exist_ok=True) shutil.copy2(t, m) applied += 1 continue # both sides changed if kind == "del" or not m.exists() or not is_text(rel): conflicts.append((rel, f"{kind} in track, main also changed ({'binary' if not is_text(rel) else 'text'})")) continue base_file = b if b is not None else Path(subprocess.os.devnull) res = subprocess.run(["git", "merge-file", "-p", "--diff3", "-L", "main", "-L", "base", "-L", name, str(m), str(base_file), str(t)], capture_output=True) if res.returncode == 0: if not dry: m.write_bytes(res.stdout) merged += 1 else: if not dry: Path(str(m) + ".conflict").write_bytes(res.stdout) conflicts.append((rel, f"text conflict ({res.returncode} hunks), see {rel}.conflict")) for rel, why in conflicts: print(f"CONFLICT {rel}: {why}") word = "would be" if dry else "were" print(f"MERGE {'DRY ' if dry else ''}{name}: {applied} {word} copied or deleted, {merged} merged cleanly, " f"{len(conflicts)} conflicts") return 1 if conflicts else 0 if __name__ == "__main__": if len(sys.argv) < 3 or sys.argv[1] not in {"fork", "status", "merge"}: sys.exit(__doc__) cmd, track = sys.argv[1], sys.argv[2] if cmd == "fork": fork(track) elif cmd == "status": status(track) else: sys.exit(merge(track, "--dry" in sys.argv))