using System; using System.Collections.Generic; using System.IO; using System.Linq; using UnityEditor; using UnityEngine; using Newtonsoft.Json; using UnityEditor.Callbacks; namespace Jovian.PackageSync { public sealed class PackageSyncWindow : EditorWindow { // private static PackageMapping[] defaultPackages = new PackageMapping[] { // new( // "Save System", // "Packages/com.jovian.savesystem", // @"D:\repos\unity-save-system"), // new( // "Zone System", // "Packages/com.jovian.zonesystem", // @"D:\repos\unity-zone-system") // }; private static string FilePath => Path.Combine(Directory.GetParent(Application.dataPath).FullName, "ProjectSettings/PackageSyncSettings.json"); private Vector2 scrollPosition; private bool hasChanges; private bool syncEnabled; private PackageMapping[] currentPackages = Array.Empty(); [MenuItem("Jovian/Utilities/Package Sync")] private static void ShowWindow() { var window = GetWindow("Package Sync"); window.minSize = new Vector2(400, 300); } [DidReloadScripts] private static void OnScriptsReloaded() { if (EditorPrefs.GetBool("PackageSync.AutoSync", false)) { PushToRepos(); } } private static void PushToRepos() { var packages = LoadSettings(); foreach(var package in packages) { SyncFiles(package.packagePath, package.repoPath, package.displayName); } } private static void SaveSettings(PackageMapping[] data) { var json = JsonConvert.SerializeObject(data); File.WriteAllText(FilePath, json); } private static PackageMapping[] LoadSettings() { if(!File.Exists(FilePath)) { File.Create(FilePath).Close(); File.WriteAllText(FilePath, string.Empty); return Array.Empty(); } var json = File.ReadAllText(FilePath); return JsonConvert.DeserializeObject(json); } private void OnEnable() { currentPackages = LoadSettings(); } private void OnGUI() { scrollPosition = EditorGUILayout.BeginScrollView(scrollPosition); EditorGUILayout.Space(8); EditorGUI.BeginChangeCheck(); syncEnabled = EditorGUILayout.ToggleLeft("Enable Package Sync", syncEnabled); if(EditorGUI.EndChangeCheck()) { EditorPrefs.SetBool("PackageSync.AutoSync", syncEnabled); if(syncEnabled) { PushToRepos(); } } EditorGUILayout.Space(4); EditorGUILayout.HelpBox( "Sync files between Unity packages and their standalone git repos.", MessageType.Info); EditorGUILayout.Space(8); EditorGUILayout.LabelField("Add Packages", EditorStyles.boldLabel); foreach(var package in currentPackages) { var set = new List { package.displayName, package.packagePath, package.repoPath }; EditorGUILayout.BeginHorizontal(); EditorGUI.BeginChangeCheck(); set[0] = EditorGUILayout.DelayedTextField(set[0]); set[1] = EditorGUILayout.TextField(set[1]); set[2] = EditorGUILayout.TextField(set[2]); package.selected = EditorGUILayout.Toggle(package.selected, GUILayout.Width(20)); if(EditorGUI.EndChangeCheck()) { package.displayName = set[0]; package.packagePath = set[1]; package.repoPath = set[2]; hasChanges = true; } EditorGUILayout.EndHorizontal(); } EditorGUILayout.Space(4); EditorGUILayout.BeginHorizontal(); if(GUILayout.Button("Add Package")) { var package = new PackageMapping(string.Empty, string.Empty, string.Empty); currentPackages = currentPackages.Append(package).ToArray(); SaveSettings(currentPackages); } if(GUILayout.Button("Remove Selected")) { currentPackages = currentPackages.Where(p => !p.selected).ToArray(); SaveSettings(currentPackages); } EditorGUILayout.EndHorizontal(); if(hasChanges) { SaveSettings(currentPackages); } EditorGUILayout.Space(8); foreach(var package in currentPackages) { DrawPackageSection(package); EditorGUILayout.Space(12); } EditorGUILayout.Space(8); EditorGUILayout.LabelField("Bulk Actions", EditorStyles.boldLabel); EditorGUILayout.BeginHorizontal(); if(GUILayout.Button("Push All to Repos", GUILayout.Height(28))) { foreach(var package in currentPackages) { SyncFiles(package.packagePath, package.repoPath, package.displayName); } } if(GUILayout.Button("Pull All from Repos", GUILayout.Height(28))) { foreach(var package in currentPackages) { SyncFiles(package.repoPath, package.packagePath, package.displayName); } } EditorGUILayout.EndHorizontal(); EditorGUILayout.EndScrollView(); } private void DrawPackageSection(PackageMapping package) { EditorGUILayout.LabelField(package.displayName, EditorStyles.boldLabel); var packageExists = Directory.Exists(package.packagePath); var repoExists = Directory.Exists(package.repoPath); EditorGUILayout.LabelField("Package", packageExists ? package.packagePath : $"{package.packagePath} (missing)"); EditorGUILayout.LabelField("Repo", repoExists ? package.repoPath : $"{package.repoPath} (missing)"); if(!packageExists || !repoExists) { EditorGUILayout.HelpBox( $"Cannot sync: {(!packageExists ? "package" : "repo")} directory not found.", MessageType.Warning); return; } EditorGUILayout.BeginHorizontal(); if(GUILayout.Button("Push to Repo →")) { SyncFiles(package.packagePath, package.repoPath, package.displayName); } if(GUILayout.Button("← Pull from Repo")) { SyncFiles(package.repoPath, package.packagePath, package.displayName); } EditorGUILayout.EndHorizontal(); if(GUILayout.Button("Show Differences")) { ShowDifferences(package); } } private static void SyncFiles(string sourcePath, string destPath, string displayName) { var fullSource = Path.GetFullPath(sourcePath); var fullDest = Path.GetFullPath(destPath); var sourceFiles = Directory.GetFiles(fullSource, "*", SearchOption.AllDirectories) .Where(f => !IsGitPath(f) && !f.EndsWith(".meta")) .ToArray(); var copied = 0; var skipped = 0; foreach(var sourceFile in sourceFiles) { var relativePath = sourceFile.Substring(fullSource.Length).TrimStart(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); var destFile = Path.Combine(fullDest, relativePath); if(IsFileIdentical(sourceFile, destFile)) { skipped++; continue; } var destDir = Path.GetDirectoryName(destFile); if(!Directory.Exists(destDir)) { if(destDir != null) { Directory.CreateDirectory(destDir); } } File.Copy(sourceFile, destFile, true); copied++; } // Remove files in dest that no longer exist in source var destFiles = Directory.GetFiles(fullDest, "*", SearchOption.AllDirectories) .Where(f => !IsGitPath(f) && !f.EndsWith(".meta")) .ToArray(); var deleted = 0; foreach(var destFile in destFiles) { var relativePath = destFile.Substring(fullDest.Length).TrimStart(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); var sourceFile = Path.Combine(fullSource, relativePath); if(!File.Exists(sourceFile)) { File.Delete(destFile); deleted++; } } var direction = fullSource == Path.GetFullPath(destPath) ? "Pulled" : "Pushed"; Debug.Log($"[PackageSync] {displayName}: {direction} {copied} files, {skipped} unchanged, {deleted} removed."); AssetDatabase.Refresh(); } private static void ShowDifferences(PackageMapping package) { var fullPackage = Path.GetFullPath(package.packagePath); var fullRepo = Path.GetFullPath(package.repoPath); var packageFiles = Directory.GetFiles(fullPackage, "*", SearchOption.AllDirectories) .Where(f => !IsGitPath(f) && !f.EndsWith(".meta")) .Select(f => f.Substring(fullPackage.Length).TrimStart(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)) .ToArray(); var repoFiles = Directory.GetFiles(fullRepo, "*", SearchOption.AllDirectories) .Where(f => !IsGitPath(f) && !f.EndsWith(".meta")) .Select(f => f.Substring(fullRepo.Length).TrimStart(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)) .ToArray(); var onlyInPackage = packageFiles.Except(repoFiles).ToArray(); var onlyInRepo = repoFiles.Except(packageFiles).ToArray(); var common = packageFiles.Intersect(repoFiles).ToArray(); var modified = 0; foreach(var file in common) { var packageFile = Path.Combine(fullPackage, file); var repoFile = Path.Combine(fullRepo, file); if(!IsFileIdentical(packageFile, repoFile)) { Debug.Log($"[PackageSync] Modified: {file}"); modified++; } } foreach(var file in onlyInPackage) { Debug.Log($"[PackageSync] Only in package: {file}"); } foreach(var file in onlyInRepo) { Debug.Log($"[PackageSync] Only in repo: {file}"); } var total = modified + onlyInPackage.Length + onlyInRepo.Length; if(total == 0) { Debug.Log($"[PackageSync] {package.displayName}: In sync — no differences found."); } else { Debug.Log($"[PackageSync] {package.displayName}: {modified} modified, {onlyInPackage.Length} only in package, {onlyInRepo.Length} only in repo."); } } private static bool IsGitPath(string path) { return path.Replace('\\', '/').Contains("/.git/") || path.Replace('\\', '/').Contains("/.git"); } private static bool IsFileIdentical(string fileA, string fileB) { if(!File.Exists(fileA) || !File.Exists(fileB)) { return false; } var infoA = new FileInfo(fileA); var infoB = new FileInfo(fileB); if(infoA.Length != infoB.Length) { return false; } var bytesA = File.ReadAllBytes(fileA); var bytesB = File.ReadAllBytes(fileB); return bytesA.SequenceEqual(bytesB); } private sealed class PackageMapping { public string displayName; public string packagePath; public string repoPath; public bool selected; public PackageMapping(string displayName, string packagePath, string repoPath) { this.displayName = displayName; this.packagePath = packagePath; this.repoPath = repoPath; } } } }