From 6eb0128f38e37b37a2a6f5f2f8504d275b24c4aa Mon Sep 17 00:00:00 2001 From: hatayama Date: Tue, 2 Jun 2026 01:54:24 +0900 Subject: [PATCH 1/2] Auto-resolve external Scene changes before compile Reload or save open Scene files that changed on disk before AssetDatabase.Refresh so autonomous compile runs do not block on Unity's external-change dialog. Add --stop-on-external-scene-changes for callers that want compile to stop instead of resolving the Scene state automatically. --- .agents/skills/uloop-compile/SKILL.md | 6 +- .claude/skills/uloop-compile/SKILL.md | 6 +- .../ExternalSceneChangeResolverTests.cs | 168 ++++++++++ .../ExternalSceneChangeResolverTests.cs.meta | 11 + .../Compile/CompilationExecutionService.cs | 1 + .../Compile/CompileController.cs | 73 +++- .../FirstPartyTools/Compile/CompileSchema.cs | 6 + .../FirstPartyTools/Compile/CompileTool.cs | 1 + .../FirstPartyTools/Compile/CompileUseCase.cs | 3 +- .../Compile/ExternalSceneChangeResolver.cs | 314 ++++++++++++++++++ .../ExternalSceneChangeResolver.cs.meta | 11 + .../FirstPartyTools/Compile/Skill/SKILL.md | 6 +- .../Compile/UnityCliLoopCompileTypes.cs | 1 + .../FirstPartyToolsEditorStartup.cs | 1 + cli/internal/cli/command_help.go | 6 + cli/internal/cli/completion_test.go | 5 +- cli/internal/cli/help_test.go | 3 + cli/internal/cli/tools.go | 18 +- cli/internal/cli/tools_test.go | 42 +++ cli/internal/tools/default-tools.json | 5 + 20 files changed, 677 insertions(+), 10 deletions(-) create mode 100644 Assets/Tests/Editor/ExternalSceneChangeResolverTests.cs create mode 100644 Assets/Tests/Editor/ExternalSceneChangeResolverTests.cs.meta create mode 100644 Packages/src/Editor/FirstPartyTools/Compile/ExternalSceneChangeResolver.cs create mode 100644 Packages/src/Editor/FirstPartyTools/Compile/ExternalSceneChangeResolver.cs.meta diff --git a/.agents/skills/uloop-compile/SKILL.md b/.agents/skills/uloop-compile/SKILL.md index 171695f6f1..2efef968d7 100644 --- a/.agents/skills/uloop-compile/SKILL.md +++ b/.agents/skills/uloop-compile/SKILL.md @@ -11,7 +11,7 @@ Execute Unity project compilation. ## Usage ```bash -uloop compile [--force-recompile] [--no-wait-for-domain-reload] +uloop compile [--force-recompile] [--no-wait-for-domain-reload] [--stop-on-external-scene-changes] ``` ## Parameters @@ -20,6 +20,7 @@ uloop compile [--force-recompile] [--no-wait-for-domain-reload] |-----------|------|---------|-------------| | `--force-recompile` | flag | - | Use for broader validation, including warnings hidden by other asmdefs; much slower than normal compile | | `--no-wait-for-domain-reload` | flag | - | Return before Domain Reload completion | +| `--stop-on-external-scene-changes` | flag | - | Stop before compilation if open Scene files changed externally instead of auto-reloading them | ## Global Options @@ -35,6 +36,9 @@ uloop compile # Start compilation without waiting for Domain Reload completion uloop compile --no-wait-for-domain-reload + +# Stop instead of auto-reloading externally changed open Scene files +uloop compile --stop-on-external-scene-changes ``` ## Output diff --git a/.claude/skills/uloop-compile/SKILL.md b/.claude/skills/uloop-compile/SKILL.md index 171695f6f1..2efef968d7 100644 --- a/.claude/skills/uloop-compile/SKILL.md +++ b/.claude/skills/uloop-compile/SKILL.md @@ -11,7 +11,7 @@ Execute Unity project compilation. ## Usage ```bash -uloop compile [--force-recompile] [--no-wait-for-domain-reload] +uloop compile [--force-recompile] [--no-wait-for-domain-reload] [--stop-on-external-scene-changes] ``` ## Parameters @@ -20,6 +20,7 @@ uloop compile [--force-recompile] [--no-wait-for-domain-reload] |-----------|------|---------|-------------| | `--force-recompile` | flag | - | Use for broader validation, including warnings hidden by other asmdefs; much slower than normal compile | | `--no-wait-for-domain-reload` | flag | - | Return before Domain Reload completion | +| `--stop-on-external-scene-changes` | flag | - | Stop before compilation if open Scene files changed externally instead of auto-reloading them | ## Global Options @@ -35,6 +36,9 @@ uloop compile # Start compilation without waiting for Domain Reload completion uloop compile --no-wait-for-domain-reload + +# Stop instead of auto-reloading externally changed open Scene files +uloop compile --stop-on-external-scene-changes ``` ## Output diff --git a/Assets/Tests/Editor/ExternalSceneChangeResolverTests.cs b/Assets/Tests/Editor/ExternalSceneChangeResolverTests.cs new file mode 100644 index 0000000000..5dadc26906 --- /dev/null +++ b/Assets/Tests/Editor/ExternalSceneChangeResolverTests.cs @@ -0,0 +1,168 @@ +using System; +using System.Collections.Generic; +using NUnit.Framework; + +using io.github.hatayama.UnityCliLoop.FirstPartyTools; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor +{ + /// + /// Tests external Scene change resolution without invoking Unity's real Scene loading APIs. + /// + public sealed class ExternalSceneChangeResolverTests + { + private const string ScenePath = "Assets/Scenes/SampleScene.unity"; + private static readonly DateTime SavedTime = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc); + private static readonly DateTime ChangedTime = new DateTime(2026, 1, 1, 0, 1, 0, DateTimeKind.Utc); + + [Test] + public void ResolveExternalSceneChanges_WhenCleanSceneChangedAndReloadEnabled_ReloadsSceneSetup() + { + // Verifies default compile behavior reloads clean externally changed Scenes. + Dictionary snapshots = CreateSnapshots(); + bool saveWasCalled = false; + bool reloadWasCalled = false; + ExternalSceneChangeResolver resolver = new ExternalSceneChangeResolver( + snapshots, + () => new[] { (AssetPath: ScenePath, IsDirty: false) }, + _ => (true, ChangedTime, 20), + _ => + { + saveWasCalled = true; + return true; + }, + () => + { + reloadWasCalled = true; + return true; + }); + + (bool CanProceed, string Message, string[] ScenePaths) result = resolver.ResolveExternalSceneChanges( + reloadExternalSceneChanges: true); + + Assert.That(result.CanProceed, Is.True); + Assert.That(saveWasCalled, Is.False); + Assert.That(reloadWasCalled, Is.True); + } + + [Test] + public void ResolveExternalSceneChanges_WhenDirtySceneChangedAndReloadEnabled_SavesScene() + { + // Verifies default compile behavior saves dirty externally changed Scenes before continuing. + Dictionary snapshots = CreateSnapshots(); + bool saveWasCalled = false; + bool reloadWasCalled = false; + ExternalSceneChangeResolver resolver = new ExternalSceneChangeResolver( + snapshots, + () => new[] { (AssetPath: ScenePath, IsDirty: true) }, + _ => (true, ChangedTime, 20), + _ => + { + saveWasCalled = true; + return true; + }, + () => + { + reloadWasCalled = true; + return true; + }); + + (bool CanProceed, string Message, string[] ScenePaths) result = resolver.ResolveExternalSceneChanges( + reloadExternalSceneChanges: true); + + Assert.That(result.CanProceed, Is.True); + Assert.That(saveWasCalled, Is.True); + Assert.That(reloadWasCalled, Is.False); + } + + [Test] + public void ResolveExternalSceneChanges_WhenStopPolicyIsEnabled_ReturnsFailureWithoutMutation() + { + // Verifies stop-on-external-scene-changes prevents automatic save or reload. + Dictionary snapshots = CreateSnapshots(); + bool saveWasCalled = false; + bool reloadWasCalled = false; + ExternalSceneChangeResolver resolver = new ExternalSceneChangeResolver( + snapshots, + () => new[] { (AssetPath: ScenePath, IsDirty: true) }, + _ => (true, ChangedTime, 20), + _ => + { + saveWasCalled = true; + return true; + }, + () => + { + reloadWasCalled = true; + return true; + }); + + (bool CanProceed, string Message, string[] ScenePaths) result = resolver.ResolveExternalSceneChanges( + reloadExternalSceneChanges: false); + + Assert.That(result.CanProceed, Is.False); + Assert.That(result.Message, Does.Contain("--stop-on-external-scene-changes")); + Assert.That(result.ScenePaths, Is.EqualTo(new[] { ScenePath })); + Assert.That(saveWasCalled, Is.False); + Assert.That(reloadWasCalled, Is.False); + } + + [Test] + public void ResolveExternalSceneChanges_WhenDirtySaveFails_ReturnsFailure() + { + // Verifies compile stops when a dirty externally changed Scene cannot be saved. + Dictionary snapshots = CreateSnapshots(); + ExternalSceneChangeResolver resolver = new ExternalSceneChangeResolver( + snapshots, + () => new[] { (AssetPath: ScenePath, IsDirty: true) }, + _ => (true, ChangedTime, 20), + _ => false, + () => true); + + (bool CanProceed, string Message, string[] ScenePaths) result = resolver.ResolveExternalSceneChanges( + reloadExternalSceneChanges: true); + + Assert.That(result.CanProceed, Is.False); + Assert.That(result.Message, Does.Contain("could not be saved or reloaded")); + Assert.That(result.ScenePaths, Is.EqualTo(new[] { ScenePath })); + } + + [Test] + public void ResolveExternalSceneChanges_WhenSceneUnchanged_DoesNotSaveOrReload() + { + // Verifies unchanged Scene fingerprints do not trigger compile preflight work. + Dictionary snapshots = CreateSnapshots(); + bool saveWasCalled = false; + bool reloadWasCalled = false; + ExternalSceneChangeResolver resolver = new ExternalSceneChangeResolver( + snapshots, + () => new[] { (AssetPath: ScenePath, IsDirty: true) }, + _ => (true, SavedTime, 10), + _ => + { + saveWasCalled = true; + return true; + }, + () => + { + reloadWasCalled = true; + return true; + }); + + (bool CanProceed, string Message, string[] ScenePaths) result = resolver.ResolveExternalSceneChanges( + reloadExternalSceneChanges: true); + + Assert.That(result.CanProceed, Is.True); + Assert.That(saveWasCalled, Is.False); + Assert.That(reloadWasCalled, Is.False); + } + + private static Dictionary CreateSnapshots() + { + Dictionary snapshots = + new Dictionary(StringComparer.Ordinal); + snapshots[ScenePath] = (true, SavedTime, 10); + return snapshots; + } + } +} diff --git a/Assets/Tests/Editor/ExternalSceneChangeResolverTests.cs.meta b/Assets/Tests/Editor/ExternalSceneChangeResolverTests.cs.meta new file mode 100644 index 0000000000..3a24031af6 --- /dev/null +++ b/Assets/Tests/Editor/ExternalSceneChangeResolverTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: e0b638aca4fe778f0006af8499fadd2a +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/Compile/CompilationExecutionService.cs b/Packages/src/Editor/FirstPartyTools/Compile/CompilationExecutionService.cs index bc40c61ba6..78d14c761f 100644 --- a/Packages/src/Editor/FirstPartyTools/Compile/CompilationExecutionService.cs +++ b/Packages/src/Editor/FirstPartyTools/Compile/CompilationExecutionService.cs @@ -24,6 +24,7 @@ public async Task ExecuteCompilationAsync(UnityCliLoopCompileRequ using CompileController compileController = new(); compileController.SetResultRecordingContext(CompileResultRecordingContext.Create(request)); + compileController.SetExternalSceneChangePolicy(request.ReloadExternalSceneChanges); return await compileController.TryCompileAsync(request.ForceRecompile, ct); } } diff --git a/Packages/src/Editor/FirstPartyTools/Compile/CompileController.cs b/Packages/src/Editor/FirstPartyTools/Compile/CompileController.cs index 92907b5f25..1a82e2c3a6 100644 --- a/Packages/src/Editor/FirstPartyTools/Compile/CompileController.cs +++ b/Packages/src/Editor/FirstPartyTools/Compile/CompileController.cs @@ -22,6 +22,7 @@ public class CompileController : IDisposable private List _compileMessages = new(); private TaskCompletionSource _currentCompileTask; private bool _isForceCompile = false; + private bool _reloadExternalSceneChanges = true; private CompileResultRecordingContext _resultRecordingContext = CompileResultRecordingContext.Disabled(); /// @@ -57,6 +58,14 @@ internal void SetResultRecordingContext(CompileResultRecordingContext resultReco _resultRecordingContext = resultRecordingContext; } + /// + /// Sets how compile handles open Scene files changed outside Unity before asset refresh. + /// + internal void SetExternalSceneChangePolicy(bool reloadExternalSceneChanges) + { + _reloadExternalSceneChanges = reloadExternalSceneChanges; + } + /// /// Executes compilation asynchronously. /// @@ -108,6 +117,21 @@ public async Task TryCompileAsync(bool forceRecompile, Cancellati ); } + (bool CanProceed, string Message, string[] ScenePaths) sceneChangeResult = + ExternalSceneChangeTracker.ResolveForCompile(_reloadExternalSceneChanges); + if (!sceneChangeResult.CanProceed) + { + VibeLogger.LogWarning( + "compile_external_scene_change_resolution_failed", + sceneChangeResult.Message, + new + { + reload_external_scene_changes = _reloadExternalSceneChanges, + scene_paths = sceneChangeResult.ScenePaths + }); + return CreateExternalSceneChangeFailureResult(sceneChangeResult); + } + _isCompiling = true; _compileMessages.Clear(); TaskCompletionSource compileTask = new(); @@ -270,7 +294,8 @@ private Dictionary BuildCompileControllerStateContext( ["editor_compiling"] = EditorApplication.isCompiling, ["editor_updating"] = EditorApplication.isUpdating, ["editor_playing"] = EditorApplication.isPlaying, - ["editor_paused"] = EditorApplication.isPaused + ["editor_paused"] = EditorApplication.isPaused, + ["reload_external_scene_changes"] = _reloadExternalSceneChanges }; if (extraContext == null) { @@ -643,6 +668,51 @@ private static CompileResult CreateAssemblyDefinitionFailureResult( ); } + /// + /// Creates a failed compile result for external Scene changes that cannot be auto-resolved. + /// + private static CompileResult CreateExternalSceneChangeFailureResult( + (bool CanProceed, string Message, string[] ScenePaths) sceneChangeResult) + { + UnityEngine.Debug.Assert(!sceneChangeResult.CanProceed, "sceneChangeResult must be a failure"); + + CompilerMessage[] errors = CreateExternalSceneChangeCompilerMessages(sceneChangeResult); + return new CompileResult( + success: false, + errorCount: errors.Length, + warningCount: 0, + completedAt: DateTime.Now, + messages: errors, + errors: errors, + warnings: Array.Empty(), + message: sceneChangeResult.Message + ); + } + + /// + /// Converts unresolved external Scene changes into compiler-shaped errors for compile responses. + /// + private static CompilerMessage[] CreateExternalSceneChangeCompilerMessages( + (bool CanProceed, string Message, string[] ScenePaths) sceneChangeResult) + { + UnityEngine.Debug.Assert(sceneChangeResult.ScenePaths != null, "scene paths must not be null"); + UnityEngine.Debug.Assert(sceneChangeResult.ScenePaths.Length > 0, "scene paths must not be empty"); + + CompilerMessage[] errors = new CompilerMessage[sceneChangeResult.ScenePaths.Length]; + for (int i = 0; i < sceneChangeResult.ScenePaths.Length; i++) + { + errors[i] = new CompilerMessage + { + type = CompilerMessageType.Error, + message = sceneChangeResult.Message, + file = sceneChangeResult.ScenePaths[i], + line = 0 + }; + } + + return errors; + } + /// /// Converts Assembly Definition and Assembly Reference Console errors into compiler messages. /// @@ -682,6 +752,7 @@ public void Cleanup() } _isCompiling = false; _isForceCompile = false; + _reloadExternalSceneChanges = true; _resultRecordingContext = CompileResultRecordingContext.Disabled(); } diff --git a/Packages/src/Editor/FirstPartyTools/Compile/CompileSchema.cs b/Packages/src/Editor/FirstPartyTools/Compile/CompileSchema.cs index a04d5c9dca..a271a553d1 100644 --- a/Packages/src/Editor/FirstPartyTools/Compile/CompileSchema.cs +++ b/Packages/src/Editor/FirstPartyTools/Compile/CompileSchema.cs @@ -20,6 +20,12 @@ public class CompileSchema : UnityCliLoopToolSchema /// public bool WaitForDomainReload { get; set; } = true; + /// + /// Whether to reload or save externally changed open Scene files before compilation refresh. + /// + [Description("Automatically reload or save open Scene files changed outside Unity before compiling")] + public bool ReloadExternalSceneChanges { get; set; } = true; + /// /// Internal request identifier used for delayed result recovery across domain reload. /// diff --git a/Packages/src/Editor/FirstPartyTools/Compile/CompileTool.cs b/Packages/src/Editor/FirstPartyTools/Compile/CompileTool.cs index be347c37dd..4830fdc75e 100644 --- a/Packages/src/Editor/FirstPartyTools/Compile/CompileTool.cs +++ b/Packages/src/Editor/FirstPartyTools/Compile/CompileTool.cs @@ -33,6 +33,7 @@ private static UnityCliLoopCompileRequest ToRequest(CompileSchema parameters) { ForceRecompile = parameters.ForceRecompile, WaitForDomainReload = parameters.WaitForDomainReload, + ReloadExternalSceneChanges = parameters.ReloadExternalSceneChanges, RequestId = parameters.RequestId, }; } diff --git a/Packages/src/Editor/FirstPartyTools/Compile/CompileUseCase.cs b/Packages/src/Editor/FirstPartyTools/Compile/CompileUseCase.cs index 4962201a57..4ef4de6191 100644 --- a/Packages/src/Editor/FirstPartyTools/Compile/CompileUseCase.cs +++ b/Packages/src/Editor/FirstPartyTools/Compile/CompileUseCase.cs @@ -271,7 +271,8 @@ private static object BuildCompileLogContext(UnityCliLoopCompileRequest request) { request_id = request.RequestId, force_recompile = request.ForceRecompile, - wait_for_domain_reload = request.WaitForDomainReload + wait_for_domain_reload = request.WaitForDomainReload, + reload_external_scene_changes = request.ReloadExternalSceneChanges }; } diff --git a/Packages/src/Editor/FirstPartyTools/Compile/ExternalSceneChangeResolver.cs b/Packages/src/Editor/FirstPartyTools/Compile/ExternalSceneChangeResolver.cs new file mode 100644 index 0000000000..b0c9dccf8d --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/Compile/ExternalSceneChangeResolver.cs @@ -0,0 +1,314 @@ +using System; +using System.Collections.Generic; +using System.IO; +using UnityEditor; +using UnityEditor.SceneManagement; +using UnityEngine; +using UnityEngine.SceneManagement; + +using io.github.hatayama.UnityCliLoop.ToolContracts; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// Tracks open Scene file snapshots so compile can resolve external disk changes before asset refresh. + /// + internal static class ExternalSceneChangeTracker + { + private static readonly Dictionary Snapshots = + new Dictionary(StringComparer.Ordinal); + private static bool _initialized; + + public static void Initialize() + { + if (_initialized) + { + return; + } + + _initialized = true; + EditorSceneManager.sceneOpened -= HandleSceneOpened; + EditorSceneManager.sceneOpened += HandleSceneOpened; + EditorSceneManager.sceneSaved -= HandleSceneSaved; + EditorSceneManager.sceneSaved += HandleSceneSaved; + EditorSceneManager.sceneClosed -= HandleSceneClosed; + EditorSceneManager.sceneClosed += HandleSceneClosed; + RecordOpenSceneSnapshots(); + } + + public static (bool CanProceed, string Message, string[] ScenePaths) ResolveForCompile( + bool reloadExternalSceneChanges) + { + Initialize(); + ExternalSceneChangeResolver resolver = new ExternalSceneChangeResolver( + Snapshots, + GetOpenSceneStates, + ReadSceneFileFingerprint, + SaveSceneByPath, + ReloadOpenSceneSetup); + return resolver.ResolveExternalSceneChanges(reloadExternalSceneChanges); + } + + private static void HandleSceneOpened(Scene scene, OpenSceneMode mode) + { + RecordSceneSnapshot(scene); + } + + private static void HandleSceneSaved(Scene scene) + { + RecordSceneSnapshot(scene); + } + + private static void HandleSceneClosed(Scene scene) + { + if (!IsTrackableScene(scene)) + { + return; + } + + Snapshots.Remove(NormalizeAssetPath(scene.path)); + } + + private static void RecordOpenSceneSnapshots() + { + (string AssetPath, bool IsDirty)[] scenes = GetOpenSceneStates(); + for (int i = 0; i < scenes.Length; i++) + { + Snapshots[scenes[i].AssetPath] = ReadSceneFileFingerprint(scenes[i].AssetPath); + } + } + + private static void RecordSceneSnapshot(Scene scene) + { + if (!IsTrackableScene(scene)) + { + return; + } + + string assetPath = NormalizeAssetPath(scene.path); + Snapshots[assetPath] = ReadSceneFileFingerprint(assetPath); + } + + private static (string AssetPath, bool IsDirty)[] GetOpenSceneStates() + { + List<(string AssetPath, bool IsDirty)> scenes = new List<(string AssetPath, bool IsDirty)>(); + for (int i = 0; i < SceneManager.sceneCount; i++) + { + Scene scene = SceneManager.GetSceneAt(i); + if (!IsTrackableScene(scene)) + { + continue; + } + + scenes.Add((NormalizeAssetPath(scene.path), scene.isDirty)); + } + + return scenes.ToArray(); + } + + private static bool IsTrackableScene(Scene scene) + { + return scene.IsValid() && + scene.isLoaded && + !string.IsNullOrEmpty(scene.path) && + scene.path.EndsWith(".unity", StringComparison.OrdinalIgnoreCase); + } + + private static (bool Exists, DateTime LastWriteTimeUtc, long Length) ReadSceneFileFingerprint( + string assetPath) + { + Debug.Assert(!string.IsNullOrEmpty(assetPath), "assetPath must not be empty"); + + string projectRoot = UnityCliLoopPathResolver.GetProjectRoot(); + string fullPath = Path.GetFullPath(Path.Combine(projectRoot, assetPath)); + FileInfo fileInfo = new FileInfo(fullPath); + if (!fileInfo.Exists) + { + return (false, DateTime.MinValue, 0); + } + + return (true, fileInfo.LastWriteTimeUtc, fileInfo.Length); + } + + private static bool SaveSceneByPath(string assetPath) + { + Debug.Assert(!string.IsNullOrEmpty(assetPath), "assetPath must not be empty"); + + Scene scene = SceneManager.GetSceneByPath(assetPath); + if (!scene.IsValid() || !scene.isLoaded) + { + return false; + } + + return EditorSceneManager.SaveScene(scene); + } + + private static bool ReloadOpenSceneSetup() + { + SceneSetup[] sceneSetup = EditorSceneManager.GetSceneManagerSetup(); + if (sceneSetup == null || sceneSetup.Length == 0) + { + return true; + } + + EditorSceneManager.RestoreSceneManagerSetup(sceneSetup); + RecordOpenSceneSnapshots(); + return true; + } + + private static string NormalizeAssetPath(string assetPath) + { + Debug.Assert(!string.IsNullOrEmpty(assetPath), "assetPath must not be empty"); + return assetPath.Replace('\\', '/'); + } + } + + /// + /// Resolves external Scene file changes according to the compile request policy. + /// + internal sealed class ExternalSceneChangeResolver + { + private const string StopOnExternalSceneChangesOption = "--stop-on-external-scene-changes"; + private readonly Dictionary _snapshots; + private readonly Func<(string AssetPath, bool IsDirty)[]> _getOpenScenes; + private readonly Func _readFileFingerprint; + private readonly Func _saveScene; + private readonly Func _reloadOpenSceneSetup; + + public ExternalSceneChangeResolver( + Dictionary snapshots, + Func<(string AssetPath, bool IsDirty)[]> getOpenScenes, + Func readFileFingerprint, + Func saveScene, + Func reloadOpenSceneSetup) + { + Debug.Assert(snapshots != null, "snapshots must not be null"); + Debug.Assert(getOpenScenes != null, "getOpenScenes must not be null"); + Debug.Assert(readFileFingerprint != null, "readFileFingerprint must not be null"); + Debug.Assert(saveScene != null, "saveScene must not be null"); + Debug.Assert(reloadOpenSceneSetup != null, "reloadOpenSceneSetup must not be null"); + + _snapshots = snapshots ?? throw new ArgumentNullException(nameof(snapshots)); + _getOpenScenes = getOpenScenes ?? throw new ArgumentNullException(nameof(getOpenScenes)); + _readFileFingerprint = readFileFingerprint ?? throw new ArgumentNullException(nameof(readFileFingerprint)); + _saveScene = saveScene ?? throw new ArgumentNullException(nameof(saveScene)); + _reloadOpenSceneSetup = reloadOpenSceneSetup ?? throw new ArgumentNullException(nameof(reloadOpenSceneSetup)); + } + + public (bool CanProceed, string Message, string[] ScenePaths) ResolveExternalSceneChanges( + bool reloadExternalSceneChanges) + { + (string AssetPath, bool IsDirty)[] openScenes = _getOpenScenes(); + List changedScenePaths = new List(); + List unresolvedScenePaths = new List(); + bool shouldReloadSceneSetup = false; + + for (int i = 0; i < openScenes.Length; i++) + { + (string AssetPath, bool IsDirty) scene = openScenes[i]; + (bool Exists, DateTime LastWriteTimeUtc, long Length) currentFingerprint = + _readFileFingerprint(scene.AssetPath); + if (!_snapshots.ContainsKey(scene.AssetPath)) + { + _snapshots[scene.AssetPath] = currentFingerprint; + continue; + } + + if (HasSameFileState(_snapshots[scene.AssetPath], currentFingerprint)) + { + continue; + } + + changedScenePaths.Add(scene.AssetPath); + if (!reloadExternalSceneChanges) + { + continue; + } + + if (!currentFingerprint.Exists) + { + unresolvedScenePaths.Add(scene.AssetPath); + continue; + } + + if (scene.IsDirty) + { + if (!_saveScene(scene.AssetPath)) + { + unresolvedScenePaths.Add(scene.AssetPath); + continue; + } + + _snapshots[scene.AssetPath] = _readFileFingerprint(scene.AssetPath); + continue; + } + + shouldReloadSceneSetup = true; + } + + if (changedScenePaths.Count > 0 && !reloadExternalSceneChanges) + { + return (false, CreateStoppedMessage(changedScenePaths.ToArray()), changedScenePaths.ToArray()); + } + + if (unresolvedScenePaths.Count > 0) + { + return (false, CreateUnresolvedMessage(unresolvedScenePaths.ToArray()), unresolvedScenePaths.ToArray()); + } + + if (shouldReloadSceneSetup && !_reloadOpenSceneSetup()) + { + return (false, CreateReloadFailureMessage(changedScenePaths.ToArray()), changedScenePaths.ToArray()); + } + + return (true, null, Array.Empty()); + } + + private static bool HasSameFileState( + (bool Exists, DateTime LastWriteTimeUtc, long Length) previousFingerprint, + (bool Exists, DateTime LastWriteTimeUtc, long Length) currentFingerprint) + { + return previousFingerprint.Exists == currentFingerprint.Exists && + previousFingerprint.LastWriteTimeUtc == currentFingerprint.LastWriteTimeUtc && + previousFingerprint.Length == currentFingerprint.Length; + } + + private static string CreateStoppedMessage(string[] scenePaths) + { + return "Compilation stopped because open Scene files changed externally. " + + "External Scene changes: " + + FormatScenePaths(scenePaths) + + $". Rerun without {StopOnExternalSceneChangesOption} to reload or save them automatically."; + } + + private static string CreateUnresolvedMessage(string[] scenePaths) + { + return "Compilation cannot resolve externally changed Scene files before compile. " + + "Scenes that could not be saved or reloaded: " + + FormatScenePaths(scenePaths) + + "."; + } + + private static string CreateReloadFailureMessage(string[] scenePaths) + { + return "Compilation cannot reload externally changed Scene files before compile. " + + "Scenes that could not be reloaded: " + + FormatScenePaths(scenePaths) + + "."; + } + + private static string FormatScenePaths(string[] scenePaths) + { + Debug.Assert(scenePaths != null, "scenePaths must not be null"); + Debug.Assert(scenePaths.Length > 0, "scenePaths must not be empty"); + + string[] displayPaths = new string[scenePaths.Length]; + for (int i = 0; i < scenePaths.Length; i++) + { + displayPaths[i] = "Scene: " + scenePaths[i]; + } + + return string.Join(", ", displayPaths); + } + } +} diff --git a/Packages/src/Editor/FirstPartyTools/Compile/ExternalSceneChangeResolver.cs.meta b/Packages/src/Editor/FirstPartyTools/Compile/ExternalSceneChangeResolver.cs.meta new file mode 100644 index 0000000000..df2dced132 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/Compile/ExternalSceneChangeResolver.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: fcf0f6dd6c867336dc66beacabb5ae40 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/Compile/Skill/SKILL.md b/Packages/src/Editor/FirstPartyTools/Compile/Skill/SKILL.md index 171695f6f1..2efef968d7 100644 --- a/Packages/src/Editor/FirstPartyTools/Compile/Skill/SKILL.md +++ b/Packages/src/Editor/FirstPartyTools/Compile/Skill/SKILL.md @@ -11,7 +11,7 @@ Execute Unity project compilation. ## Usage ```bash -uloop compile [--force-recompile] [--no-wait-for-domain-reload] +uloop compile [--force-recompile] [--no-wait-for-domain-reload] [--stop-on-external-scene-changes] ``` ## Parameters @@ -20,6 +20,7 @@ uloop compile [--force-recompile] [--no-wait-for-domain-reload] |-----------|------|---------|-------------| | `--force-recompile` | flag | - | Use for broader validation, including warnings hidden by other asmdefs; much slower than normal compile | | `--no-wait-for-domain-reload` | flag | - | Return before Domain Reload completion | +| `--stop-on-external-scene-changes` | flag | - | Stop before compilation if open Scene files changed externally instead of auto-reloading them | ## Global Options @@ -35,6 +36,9 @@ uloop compile # Start compilation without waiting for Domain Reload completion uloop compile --no-wait-for-domain-reload + +# Stop instead of auto-reloading externally changed open Scene files +uloop compile --stop-on-external-scene-changes ``` ## Output diff --git a/Packages/src/Editor/FirstPartyTools/Compile/UnityCliLoopCompileTypes.cs b/Packages/src/Editor/FirstPartyTools/Compile/UnityCliLoopCompileTypes.cs index 6bf1483b1b..66deaa0acd 100644 --- a/Packages/src/Editor/FirstPartyTools/Compile/UnityCliLoopCompileTypes.cs +++ b/Packages/src/Editor/FirstPartyTools/Compile/UnityCliLoopCompileTypes.cs @@ -18,6 +18,7 @@ public sealed class UnityCliLoopCompileRequest { public bool ForceRecompile { get; set; } public bool WaitForDomainReload { get; set; } + public bool ReloadExternalSceneChanges { get; set; } = true; public string RequestId { get; set; } = ""; } diff --git a/Packages/src/Editor/FirstPartyTools/FirstPartyToolsEditorStartup.cs b/Packages/src/Editor/FirstPartyTools/FirstPartyToolsEditorStartup.cs index 78ad1a04eb..680d9675c9 100644 --- a/Packages/src/Editor/FirstPartyTools/FirstPartyToolsEditorStartup.cs +++ b/Packages/src/Editor/FirstPartyTools/FirstPartyToolsEditorStartup.cs @@ -8,6 +8,7 @@ public static class FirstPartyToolsEditorStartup { public static void Initialize() { + ExternalSceneChangeTracker.Initialize(); ExecuteDynamicCodeEditorStartup.Initialize(); GetLogsEditorStartup.Initialize(); #if ULOOP_HAS_TEST_FRAMEWORK diff --git a/cli/internal/cli/command_help.go b/cli/internal/cli/command_help.go index bb953b4eff..9cf86f6733 100644 --- a/cli/internal/cli/command_help.go +++ b/cli/internal/cli/command_help.go @@ -150,6 +150,9 @@ func optionDescription(toolName string, propertyName string, property toolProper if isRunTestsSaveBeforeRunOption(toolName, propertyName, property) { return optionSummary(toolName, propertyName, property) + "; default: auto-save enabled" } + if isCompileReloadExternalSceneChangesOption(toolName, propertyName, property) { + return optionSummary(toolName, propertyName, property) + "; default: auto-reload enabled" + } parts := []string{} if description := optionSummary(toolName, propertyName, property); description != "" { @@ -169,6 +172,9 @@ func optionSummary(toolName string, propertyName string, property toolProperty) if isRunTestsSaveBeforeRunOption(toolName, propertyName, property) { return "Fail before execution if unsaved editor changes remain instead of auto-saving them" } + if isCompileReloadExternalSceneChangesOption(toolName, propertyName, property) { + return "Stop before execution if open Scene files changed externally instead of auto-reloading them" + } summary := firstHelpLine(property.Description) normalizedSummary := strings.ToLower(summary) if strings.HasPrefix(normalizedSummary, "disable ") || strings.HasPrefix(normalizedSummary, "do not ") { diff --git a/cli/internal/cli/completion_test.go b/cli/internal/cli/completion_test.go index a9213fdfd5..957cf64243 100644 --- a/cli/internal/cli/completion_test.go +++ b/cli/internal/cli/completion_test.go @@ -50,7 +50,7 @@ func TestCompletionListOptionsUsesToolSchema(t *testing.T) { } output := stdout.String() - for _, option := range []string{"--force-recompile", "--no-wait-for-domain-reload"} { + for _, option := range []string{"--force-recompile", "--no-wait-for-domain-reload", "--stop-on-external-scene-changes"} { if !strings.Contains(output, option) { t.Fatalf("option %s was not listed: %s", option, output) } @@ -93,6 +93,9 @@ func TestCompletionListOptionsUsesEmbeddedFirstPartyToolSchema(t *testing.T) { if !strings.Contains(output, "--no-wait-for-domain-reload") { t.Fatalf("embedded compile options were not used: %s", output) } + if !strings.Contains(output, "--stop-on-external-scene-changes") { + t.Fatalf("embedded compile external Scene option was not used: %s", output) + } if strings.Contains(output, "--wait-for-domain-reload") { t.Fatalf("stale wait option should not be listed: %s", output) } diff --git a/cli/internal/cli/help_test.go b/cli/internal/cli/help_test.go index 65147091af..d4c6ec922d 100644 --- a/cli/internal/cli/help_test.go +++ b/cli/internal/cli/help_test.go @@ -243,6 +243,9 @@ func TestRunProjectLocalCompileHelpDoesNotRequireUnityProject(t *testing.T) { "uloop compile", "--force-recompile", "--no-wait-for-domain-reload", + "--stop-on-external-scene-changes", + "Stop before execution if open Scene files changed externally instead of auto-reloading them", + "default: auto-reload enabled", } { if !strings.Contains(output, expected) { t.Fatalf("compile help missing %q:\n%s", expected, output) diff --git a/cli/internal/cli/tools.go b/cli/internal/cli/tools.go index 6aec47442b..024ec03d5c 100644 --- a/cli/internal/cli/tools.go +++ b/cli/internal/cli/tools.go @@ -13,10 +13,11 @@ import ( var version = clicontract.Current.CliVersion const ( - cacheDirectoryName = tools.CacheDirectoryName - cacheFileName = tools.CacheFileName - projectPathFlagName = "project-path" - runTestsCommandName = "run-tests" + cacheDirectoryName = tools.CacheDirectoryName + cacheFileName = tools.CacheFileName + projectPathFlagName = "project-path" + runTestsCommandName = "run-tests" + reloadExternalSceneChangesPropertyName = "ReloadExternalSceneChanges" ) type ( @@ -301,6 +302,9 @@ func optionNameForProperty(toolName string, propertyName string, property toolPr if isRunTestsSaveBeforeRunOption(toolName, propertyName, property) { return "fail-on-unsaved-changes" } + if isCompileReloadExternalSceneChangesOption(toolName, propertyName, property) { + return "stop-on-external-scene-changes" + } return "no-" + kebabName } return kebabName @@ -312,6 +316,12 @@ func isRunTestsSaveBeforeRunOption(toolName string, propertyName string, propert isNegatedBooleanProperty(property) } +func isCompileReloadExternalSceneChangesOption(toolName string, propertyName string, property toolProperty) bool { + return toolName == compileCommandName && + propertyName == reloadExternalSceneChangesPropertyName && + isNegatedBooleanProperty(property) +} + func visibleOptionNamesForTool(tool toolDefinition) []string { schema := tool.EffectiveInputSchema() options := make([]string, 0, len(schema.Properties)) diff --git a/cli/internal/cli/tools_test.go b/cli/internal/cli/tools_test.go index 74f5cea852..cf6d6e61fc 100644 --- a/cli/internal/cli/tools_test.go +++ b/cli/internal/cli/tools_test.go @@ -102,6 +102,23 @@ func TestBuildToolParamsConvertsRunTestsFailOnUnsavedChangesFlag(t *testing.T) { } } +// Tests that compile accepts --stop-on-external-scene-changes from embedded tools. +func TestBuildToolParamsConvertsCompileStopOnExternalSceneChangesFlag(t *testing.T) { + tool, ok := findTool(loadDefaultTools(), compileCommandName) + if !ok { + t.Fatal("compile was not found in default tools") + } + + params, _, err := buildToolParams([]string{"--stop-on-external-scene-changes"}, tool) + if err != nil { + t.Fatalf("buildToolParams failed: %v", err) + } + + if params[reloadExternalSceneChangesPropertyName] != false { + t.Fatalf("ReloadExternalSceneChanges mismatch: %#v", params[reloadExternalSceneChangesPropertyName]) + } +} + // Tests that run-tests-specific aliases do not leak into unrelated tool schemas. func TestBuildToolParamsKeepsGenericSaveBeforeRunFlagForOtherTools(t *testing.T) { tool := toolDefinition{ @@ -127,6 +144,31 @@ func TestBuildToolParamsKeepsGenericSaveBeforeRunFlagForOtherTools(t *testing.T) } } +// Tests that compile-specific aliases do not leak into unrelated tool schemas. +func TestBuildToolParamsKeepsGenericReloadExternalSceneChangesFlagForOtherTools(t *testing.T) { + tool := toolDefinition{ + Name: "sample-tool", + InputSchema: inputSchema{ + Properties: map[string]toolProperty{ + reloadExternalSceneChangesPropertyName: {Type: "boolean", Default: true}, + }, + }, + } + + params, _, err := buildToolParams([]string{"--no-reload-external-scene-changes"}, tool) + if err != nil { + t.Fatalf("buildToolParams failed: %v", err) + } + if params[reloadExternalSceneChangesPropertyName] != false { + t.Fatalf("ReloadExternalSceneChanges mismatch: %#v", params[reloadExternalSceneChangesPropertyName]) + } + + _, _, err = buildToolParams([]string{"--stop-on-external-scene-changes"}, tool) + if err == nil { + t.Fatal("expected compile-specific flag to be rejected") + } +} + // Tests that run-tests-specific help text does not leak into unrelated tool schemas. func TestVisibleOptionHelpEntriesKeepsGenericSaveBeforeRunHelpForOtherTools(t *testing.T) { tool := toolDefinition{ diff --git a/cli/internal/tools/default-tools.json b/cli/internal/tools/default-tools.json index a210afff6d..5463bdcfb9 100644 --- a/cli/internal/tools/default-tools.json +++ b/cli/internal/tools/default-tools.json @@ -15,6 +15,11 @@ "type": "boolean", "description": "Wait for domain reload completion before returning", "default": true + }, + "ReloadExternalSceneChanges": { + "type": "boolean", + "description": "Automatically reload or save open Scene files changed outside Unity before compiling", + "default": true } } } From 26965489f75fa2942a476a6c5437c60baa2decde Mon Sep 17 00:00:00 2001 From: hatayama Date: Tue, 2 Jun 2026 02:44:02 +0900 Subject: [PATCH 2/2] Harden external Scene compile preflight Keep actionable external Scene preflight diagnostics during force compile, bump the native CLI contract for the new compile flag, and avoid auto-saving Scenes that are both dirty in Unity and changed on disk. --- .../Editor/CliSetupApplicationServiceTests.cs | 8 +- .../CompileSessionResultServiceTests.cs | 32 ++++++ .../ExternalSceneChangeResolverTests.cs | 97 +++++++++++++------ .../ExternalSceneChangeResolverTests.cs.meta | 6 +- Packages/src/Editor/Domain/CliConstants.cs | 2 +- .../Compile/CompileController.cs | 12 ++- .../FirstPartyTools/Compile/CompileSchema.cs | 1 - .../Compile/CompileSessionResultService.cs | 2 +- .../Compile/ExternalSceneChangeResolver.cs | 80 +++++++++++---- .../ExternalSceneChangeResolver.cs.meta | 6 +- cli/contract.json | 2 +- cli/internal/tools/default-tools.json | 2 +- 12 files changed, 180 insertions(+), 70 deletions(-) diff --git a/Assets/Tests/Editor/CliSetupApplicationServiceTests.cs b/Assets/Tests/Editor/CliSetupApplicationServiceTests.cs index 553a13a43f..0db1594228 100644 --- a/Assets/Tests/Editor/CliSetupApplicationServiceTests.cs +++ b/Assets/Tests/Editor/CliSetupApplicationServiceTests.cs @@ -31,14 +31,14 @@ public async Task InstallGlobalCliAsync_UsesMinimumRequiredCliReleaseTag() } [Test] - public void GetMinimumRequiredCliVersion_RequiresCompileRecoveryCliRelease() + public void GetMinimumRequiredCliVersion_RequiresExternalSceneChangeFlagCliRelease() { - // Verifies this package release requires the CLI that polls stored compile status. + // Verifies this package release requires the CLI with external Scene change options. CliSetupApplicationService service = new( new FakeCliInstallationDetector(new string[] { null }), new FakeNativeCliInstaller()); - Assert.That(service.GetMinimumRequiredCliVersion(), Is.EqualTo("3.0.0-beta.23")); + Assert.That(service.GetMinimumRequiredCliVersion(), Is.EqualTo("3.0.0-beta.24")); } [Test] @@ -49,7 +49,7 @@ public void GetMinimumRequiredCliReleaseTag_UsesCliGitHubReleaseTag() new FakeCliInstallationDetector(new string[] { null }), new FakeNativeCliInstaller()); - Assert.That(service.GetMinimumRequiredCliReleaseTag(), Is.EqualTo("cli-v3.0.0-beta.23")); + Assert.That(service.GetMinimumRequiredCliReleaseTag(), Is.EqualTo("cli-v3.0.0-beta.24")); } [Test] diff --git a/Assets/Tests/Editor/CompileSessionResultServiceTests.cs b/Assets/Tests/Editor/CompileSessionResultServiceTests.cs index 17a8b1a613..000533691d 100644 --- a/Assets/Tests/Editor/CompileSessionResultServiceTests.cs +++ b/Assets/Tests/Editor/CompileSessionResultServiceTests.cs @@ -106,5 +106,37 @@ public void CreateCompileResult_WhenForceCompileHasOutcome_DropsCountsAndDetaile Assert.That(response.Warnings, Is.Null); Assert.That(response.Message, Is.Null); } + + [Test] + public void CreateCompileResult_WhenForceCompileHasPreservedFailure_MapsDetailedIssues() + { + // Verifies preflight failures keep actionable details even during force compile. + CompilerMessage error = new CompilerMessage + { + type = CompilerMessageType.Error, + message = "external Scene changed", + file = "Assets/Scenes/SampleScene.unity", + line = 0 + }; + CompileResult result = new CompileResult( + success: false, + errorCount: 1, + warningCount: 0, + completedAt: DateTime.Now, + messages: new[] { error }, + errors: new[] { error }, + warnings: Array.Empty(), + message: "Compilation stopped because open Scene files changed externally.", + preserveDetailsWhenForceRecompile: true); + + UnityCliLoopCompileResult response = + CompileSessionResultService.CreateCompileResult(result, forceRecompile: true); + + Assert.That(response.Success, Is.False); + Assert.That(response.ErrorCount, Is.EqualTo(1)); + Assert.That(response.Errors, Has.Length.EqualTo(1)); + Assert.That(response.Errors[0].File, Is.EqualTo("Assets/Scenes/SampleScene.unity")); + Assert.That(response.Message, Does.Contain("externally")); + } } } diff --git a/Assets/Tests/Editor/ExternalSceneChangeResolverTests.cs b/Assets/Tests/Editor/ExternalSceneChangeResolverTests.cs index 5dadc26906..fa370713c5 100644 --- a/Assets/Tests/Editor/ExternalSceneChangeResolverTests.cs +++ b/Assets/Tests/Editor/ExternalSceneChangeResolverTests.cs @@ -20,17 +20,12 @@ public void ResolveExternalSceneChanges_WhenCleanSceneChangedAndReloadEnabled_Re { // Verifies default compile behavior reloads clean externally changed Scenes. Dictionary snapshots = CreateSnapshots(); - bool saveWasCalled = false; bool reloadWasCalled = false; ExternalSceneChangeResolver resolver = new ExternalSceneChangeResolver( snapshots, () => new[] { (AssetPath: ScenePath, IsDirty: false) }, _ => (true, ChangedTime, 20), - _ => - { - saveWasCalled = true; - return true; - }, + () => Array.Empty(), () => { reloadWasCalled = true; @@ -41,25 +36,24 @@ public void ResolveExternalSceneChanges_WhenCleanSceneChangedAndReloadEnabled_Re reloadExternalSceneChanges: true); Assert.That(result.CanProceed, Is.True); - Assert.That(saveWasCalled, Is.False); Assert.That(reloadWasCalled, Is.True); } [Test] - public void ResolveExternalSceneChanges_WhenDirtySceneChangedAndReloadEnabled_SavesScene() + public void ResolveExternalSceneChanges_WhenDirtySceneChangedAndReloadEnabled_ReturnsFailureWithoutMutation() { - // Verifies default compile behavior saves dirty externally changed Scenes before continuing. + // Verifies dirty externally changed Scenes are not overwritten by automatic compile preflight. Dictionary snapshots = CreateSnapshots(); - bool saveWasCalled = false; + bool saveDirtyOpenScenesWasCalled = false; bool reloadWasCalled = false; ExternalSceneChangeResolver resolver = new ExternalSceneChangeResolver( snapshots, () => new[] { (AssetPath: ScenePath, IsDirty: true) }, _ => (true, ChangedTime, 20), - _ => + () => { - saveWasCalled = true; - return true; + saveDirtyOpenScenesWasCalled = true; + return Array.Empty(); }, () => { @@ -70,8 +64,10 @@ public void ResolveExternalSceneChanges_WhenDirtySceneChangedAndReloadEnabled_Sa (bool CanProceed, string Message, string[] ScenePaths) result = resolver.ResolveExternalSceneChanges( reloadExternalSceneChanges: true); - Assert.That(result.CanProceed, Is.True); - Assert.That(saveWasCalled, Is.True); + Assert.That(result.CanProceed, Is.False); + Assert.That(result.Message, Does.Contain("not overwritten automatically")); + Assert.That(result.ScenePaths, Is.EqualTo(new[] { ScenePath })); + Assert.That(saveDirtyOpenScenesWasCalled, Is.False); Assert.That(reloadWasCalled, Is.False); } @@ -80,16 +76,16 @@ public void ResolveExternalSceneChanges_WhenStopPolicyIsEnabled_ReturnsFailureWi { // Verifies stop-on-external-scene-changes prevents automatic save or reload. Dictionary snapshots = CreateSnapshots(); - bool saveWasCalled = false; + bool saveDirtyOpenScenesWasCalled = false; bool reloadWasCalled = false; ExternalSceneChangeResolver resolver = new ExternalSceneChangeResolver( snapshots, () => new[] { (AssetPath: ScenePath, IsDirty: true) }, _ => (true, ChangedTime, 20), - _ => + () => { - saveWasCalled = true; - return true; + saveDirtyOpenScenesWasCalled = true; + return Array.Empty(); }, () => { @@ -103,28 +99,65 @@ public void ResolveExternalSceneChanges_WhenStopPolicyIsEnabled_ReturnsFailureWi Assert.That(result.CanProceed, Is.False); Assert.That(result.Message, Does.Contain("--stop-on-external-scene-changes")); Assert.That(result.ScenePaths, Is.EqualTo(new[] { ScenePath })); - Assert.That(saveWasCalled, Is.False); + Assert.That(saveDirtyOpenScenesWasCalled, Is.False); Assert.That(reloadWasCalled, Is.False); } [Test] - public void ResolveExternalSceneChanges_WhenDirtySaveFails_ReturnsFailure() + public void ResolveExternalSceneChanges_WhenCleanSceneChangedAndReloadEnabled_SavesDirtyScenesBeforeReload() { - // Verifies compile stops when a dirty externally changed Scene cannot be saved. + // Verifies reload preflight saves other dirty open Scenes before restoring Scene setup. Dictionary snapshots = CreateSnapshots(); + bool saveDirtyOpenScenesWasCalled = false; + bool reloadWasCalled = false; ExternalSceneChangeResolver resolver = new ExternalSceneChangeResolver( snapshots, - () => new[] { (AssetPath: ScenePath, IsDirty: true) }, + () => new[] { (AssetPath: ScenePath, IsDirty: false) }, + _ => (true, ChangedTime, 20), + () => + { + saveDirtyOpenScenesWasCalled = true; + return Array.Empty(); + }, + () => + { + reloadWasCalled = true; + return true; + }); + + (bool CanProceed, string Message, string[] ScenePaths) result = resolver.ResolveExternalSceneChanges( + reloadExternalSceneChanges: true); + + Assert.That(result.CanProceed, Is.True); + Assert.That(saveDirtyOpenScenesWasCalled, Is.True); + Assert.That(reloadWasCalled, Is.True); + } + + [Test] + public void ResolveExternalSceneChanges_WhenDirtyScenesBeforeReloadCannotBeSaved_ReturnsFailure() + { + // Verifies reload does not proceed when any dirty open Scene cannot be saved first. + const string DirtyScenePath = "Assets/Scenes/DirtyScene.unity"; + Dictionary snapshots = CreateSnapshots(); + bool reloadWasCalled = false; + ExternalSceneChangeResolver resolver = new ExternalSceneChangeResolver( + snapshots, + () => new[] { (AssetPath: ScenePath, IsDirty: false) }, _ => (true, ChangedTime, 20), - _ => false, - () => true); + () => new[] { DirtyScenePath }, + () => + { + reloadWasCalled = true; + return true; + }); (bool CanProceed, string Message, string[] ScenePaths) result = resolver.ResolveExternalSceneChanges( reloadExternalSceneChanges: true); Assert.That(result.CanProceed, Is.False); - Assert.That(result.Message, Does.Contain("could not be saved or reloaded")); - Assert.That(result.ScenePaths, Is.EqualTo(new[] { ScenePath })); + Assert.That(result.Message, Does.Contain("cannot save dirty Scene files")); + Assert.That(result.ScenePaths, Is.EqualTo(new[] { DirtyScenePath })); + Assert.That(reloadWasCalled, Is.False); } [Test] @@ -132,16 +165,16 @@ public void ResolveExternalSceneChanges_WhenSceneUnchanged_DoesNotSaveOrReload() { // Verifies unchanged Scene fingerprints do not trigger compile preflight work. Dictionary snapshots = CreateSnapshots(); - bool saveWasCalled = false; + bool saveDirtyOpenScenesWasCalled = false; bool reloadWasCalled = false; ExternalSceneChangeResolver resolver = new ExternalSceneChangeResolver( snapshots, () => new[] { (AssetPath: ScenePath, IsDirty: true) }, _ => (true, SavedTime, 10), - _ => + () => { - saveWasCalled = true; - return true; + saveDirtyOpenScenesWasCalled = true; + return Array.Empty(); }, () => { @@ -153,7 +186,7 @@ public void ResolveExternalSceneChanges_WhenSceneUnchanged_DoesNotSaveOrReload() reloadExternalSceneChanges: true); Assert.That(result.CanProceed, Is.True); - Assert.That(saveWasCalled, Is.False); + Assert.That(saveDirtyOpenScenesWasCalled, Is.False); Assert.That(reloadWasCalled, Is.False); } diff --git a/Assets/Tests/Editor/ExternalSceneChangeResolverTests.cs.meta b/Assets/Tests/Editor/ExternalSceneChangeResolverTests.cs.meta index 3a24031af6..6f9a1ca8c3 100644 --- a/Assets/Tests/Editor/ExternalSceneChangeResolverTests.cs.meta +++ b/Assets/Tests/Editor/ExternalSceneChangeResolverTests.cs.meta @@ -6,6 +6,6 @@ MonoImporter: defaultReferences: [] executionOrder: 0 icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/Domain/CliConstants.cs b/Packages/src/Editor/Domain/CliConstants.cs index 7906bc05bc..aa953aac8b 100644 --- a/Packages/src/Editor/Domain/CliConstants.cs +++ b/Packages/src/Editor/Domain/CliConstants.cs @@ -6,7 +6,7 @@ namespace io.github.hatayama.UnityCliLoop.Domain public static class CliConstants { public const string EXECUTABLE_NAME = "uloop"; - public const string MINIMUM_REQUIRED_CLI_VERSION = "3.0.0-beta.23"; + public const string MINIMUM_REQUIRED_CLI_VERSION = "3.0.0-beta.24"; public const string MINIMUM_REQUIRED_CLI_RELEASE_TAG = CLI_RELEASE_TAG_PREFIX + MINIMUM_REQUIRED_CLI_VERSION; public const string VERSION_FLAG = "--version"; public const string SHORT_VERSION_FLAG = "-v"; diff --git a/Packages/src/Editor/FirstPartyTools/Compile/CompileController.cs b/Packages/src/Editor/FirstPartyTools/Compile/CompileController.cs index 1a82e2c3a6..71f6b5a303 100644 --- a/Packages/src/Editor/FirstPartyTools/Compile/CompileController.cs +++ b/Packages/src/Editor/FirstPartyTools/Compile/CompileController.cs @@ -685,7 +685,8 @@ private static CompileResult CreateExternalSceneChangeFailureResult( messages: errors, errors: errors, warnings: Array.Empty(), - message: sceneChangeResult.Message + message: sceneChangeResult.Message, + preserveDetailsWhenForceRecompile: true ); } @@ -823,6 +824,11 @@ public class CompileResult /// public string Message { get; } + /// + /// Whether force-compile response shaping must keep detailed non-compiler preflight errors. + /// + internal bool PreserveDetailsWhenForceRecompile { get; } + /// /// Alias for error messages (for backward compatibility). /// @@ -853,7 +859,8 @@ public CompileResult( CompilerMessage[] errors, CompilerMessage[] warnings, bool isIndeterminate = false, - string message = null + string message = null, + bool preserveDetailsWhenForceRecompile = false ) { Success = success; @@ -865,6 +872,7 @@ public CompileResult( Warnings = warnings; IsIndeterminate = isIndeterminate; Message = message; + PreserveDetailsWhenForceRecompile = preserveDetailsWhenForceRecompile; } } } diff --git a/Packages/src/Editor/FirstPartyTools/Compile/CompileSchema.cs b/Packages/src/Editor/FirstPartyTools/Compile/CompileSchema.cs index a271a553d1..4bc546da41 100644 --- a/Packages/src/Editor/FirstPartyTools/Compile/CompileSchema.cs +++ b/Packages/src/Editor/FirstPartyTools/Compile/CompileSchema.cs @@ -23,7 +23,6 @@ public class CompileSchema : UnityCliLoopToolSchema /// /// Whether to reload or save externally changed open Scene files before compilation refresh. /// - [Description("Automatically reload or save open Scene files changed outside Unity before compiling")] public bool ReloadExternalSceneChanges { get; set; } = true; /// diff --git a/Packages/src/Editor/FirstPartyTools/Compile/CompileSessionResultService.cs b/Packages/src/Editor/FirstPartyTools/Compile/CompileSessionResultService.cs index 455d4db0c0..c791a7792f 100644 --- a/Packages/src/Editor/FirstPartyTools/Compile/CompileSessionResultService.cs +++ b/Packages/src/Editor/FirstPartyTools/Compile/CompileSessionResultService.cs @@ -20,7 +20,7 @@ internal static UnityCliLoopCompileResult CreateCompileResult( { Debug.Assert(result != null, "result must not be null"); - if (forceRecompile) + if (forceRecompile && !result.PreserveDetailsWhenForceRecompile) { return CreateForceCompileResult(result); } diff --git a/Packages/src/Editor/FirstPartyTools/Compile/ExternalSceneChangeResolver.cs b/Packages/src/Editor/FirstPartyTools/Compile/ExternalSceneChangeResolver.cs index b0c9dccf8d..8913608b49 100644 --- a/Packages/src/Editor/FirstPartyTools/Compile/ExternalSceneChangeResolver.cs +++ b/Packages/src/Editor/FirstPartyTools/Compile/ExternalSceneChangeResolver.cs @@ -44,7 +44,7 @@ public static (bool CanProceed, string Message, string[] ScenePaths) ResolveForC Snapshots, GetOpenSceneStates, ReadSceneFileFingerprint, - SaveSceneByPath, + SaveDirtyOpenScenesBeforeReload, ReloadOpenSceneSetup); return resolver.ResolveExternalSceneChanges(reloadExternalSceneChanges); } @@ -130,17 +130,27 @@ private static (bool Exists, DateTime LastWriteTimeUtc, long Length) ReadSceneFi return (true, fileInfo.LastWriteTimeUtc, fileInfo.Length); } - private static bool SaveSceneByPath(string assetPath) + private static string[] SaveDirtyOpenScenesBeforeReload() { - Debug.Assert(!string.IsNullOrEmpty(assetPath), "assetPath must not be empty"); - - Scene scene = SceneManager.GetSceneByPath(assetPath); - if (!scene.IsValid() || !scene.isLoaded) + List failedScenePaths = new List(); + for (int i = 0; i < SceneManager.sceneCount; i++) { - return false; + Scene scene = SceneManager.GetSceneAt(i); + if (!scene.IsValid() || !scene.isLoaded || !scene.isDirty) + { + continue; + } + + if (string.IsNullOrEmpty(scene.path) || !EditorSceneManager.SaveScene(scene)) + { + failedScenePaths.Add(GetSceneDisplayPath(scene)); + continue; + } + + RecordSceneSnapshot(scene); } - return EditorSceneManager.SaveScene(scene); + return failedScenePaths.ToArray(); } private static bool ReloadOpenSceneSetup() @@ -161,6 +171,21 @@ private static string NormalizeAssetPath(string assetPath) Debug.Assert(!string.IsNullOrEmpty(assetPath), "assetPath must not be empty"); return assetPath.Replace('\\', '/'); } + + private static string GetSceneDisplayPath(Scene scene) + { + if (!string.IsNullOrEmpty(scene.path)) + { + return NormalizeAssetPath(scene.path); + } + + if (!string.IsNullOrEmpty(scene.name)) + { + return scene.name; + } + + return "Untitled scene"; + } } /// @@ -172,26 +197,27 @@ internal sealed class ExternalSceneChangeResolver private readonly Dictionary _snapshots; private readonly Func<(string AssetPath, bool IsDirty)[]> _getOpenScenes; private readonly Func _readFileFingerprint; - private readonly Func _saveScene; + private readonly Func _saveDirtyOpenScenesBeforeReload; private readonly Func _reloadOpenSceneSetup; public ExternalSceneChangeResolver( Dictionary snapshots, Func<(string AssetPath, bool IsDirty)[]> getOpenScenes, Func readFileFingerprint, - Func saveScene, + Func saveDirtyOpenScenesBeforeReload, Func reloadOpenSceneSetup) { Debug.Assert(snapshots != null, "snapshots must not be null"); Debug.Assert(getOpenScenes != null, "getOpenScenes must not be null"); Debug.Assert(readFileFingerprint != null, "readFileFingerprint must not be null"); - Debug.Assert(saveScene != null, "saveScene must not be null"); + Debug.Assert(saveDirtyOpenScenesBeforeReload != null, "saveDirtyOpenScenesBeforeReload must not be null"); Debug.Assert(reloadOpenSceneSetup != null, "reloadOpenSceneSetup must not be null"); _snapshots = snapshots ?? throw new ArgumentNullException(nameof(snapshots)); _getOpenScenes = getOpenScenes ?? throw new ArgumentNullException(nameof(getOpenScenes)); _readFileFingerprint = readFileFingerprint ?? throw new ArgumentNullException(nameof(readFileFingerprint)); - _saveScene = saveScene ?? throw new ArgumentNullException(nameof(saveScene)); + _saveDirtyOpenScenesBeforeReload = saveDirtyOpenScenesBeforeReload ?? + throw new ArgumentNullException(nameof(saveDirtyOpenScenesBeforeReload)); _reloadOpenSceneSetup = reloadOpenSceneSetup ?? throw new ArgumentNullException(nameof(reloadOpenSceneSetup)); } @@ -233,13 +259,7 @@ public ExternalSceneChangeResolver( if (scene.IsDirty) { - if (!_saveScene(scene.AssetPath)) - { - unresolvedScenePaths.Add(scene.AssetPath); - continue; - } - - _snapshots[scene.AssetPath] = _readFileFingerprint(scene.AssetPath); + unresolvedScenePaths.Add(scene.AssetPath); continue; } @@ -256,6 +276,16 @@ public ExternalSceneChangeResolver( return (false, CreateUnresolvedMessage(unresolvedScenePaths.ToArray()), unresolvedScenePaths.ToArray()); } + if (shouldReloadSceneSetup) + { + string[] dirtySceneSaveFailures = _saveDirtyOpenScenesBeforeReload(); + Debug.Assert(dirtySceneSaveFailures != null, "dirty Scene save failures must not be null"); + if (dirtySceneSaveFailures.Length > 0) + { + return (false, CreateDirtySaveBeforeReloadFailureMessage(dirtySceneSaveFailures), dirtySceneSaveFailures); + } + } + if (shouldReloadSceneSetup && !_reloadOpenSceneSetup()) { return (false, CreateReloadFailureMessage(changedScenePaths.ToArray()), changedScenePaths.ToArray()); @@ -284,9 +314,9 @@ private static string CreateStoppedMessage(string[] scenePaths) private static string CreateUnresolvedMessage(string[] scenePaths) { return "Compilation cannot resolve externally changed Scene files before compile. " + - "Scenes that could not be saved or reloaded: " + + "Scenes that could not be safely saved or reloaded: " + FormatScenePaths(scenePaths) + - "."; + ". Dirty Scenes changed externally are not overwritten automatically."; } private static string CreateReloadFailureMessage(string[] scenePaths) @@ -297,6 +327,14 @@ private static string CreateReloadFailureMessage(string[] scenePaths) "."; } + private static string CreateDirtySaveBeforeReloadFailureMessage(string[] scenePaths) + { + return "Compilation cannot save dirty Scene files before reloading externally changed Scene files. " + + "Dirty Scenes that could not be saved: " + + FormatScenePaths(scenePaths) + + "."; + } + private static string FormatScenePaths(string[] scenePaths) { Debug.Assert(scenePaths != null, "scenePaths must not be null"); diff --git a/Packages/src/Editor/FirstPartyTools/Compile/ExternalSceneChangeResolver.cs.meta b/Packages/src/Editor/FirstPartyTools/Compile/ExternalSceneChangeResolver.cs.meta index df2dced132..fe72ed774d 100644 --- a/Packages/src/Editor/FirstPartyTools/Compile/ExternalSceneChangeResolver.cs.meta +++ b/Packages/src/Editor/FirstPartyTools/Compile/ExternalSceneChangeResolver.cs.meta @@ -6,6 +6,6 @@ MonoImporter: defaultReferences: [] executionOrder: 0 icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: + userData: + assetBundleName: + assetBundleVariant: diff --git a/cli/contract.json b/cli/contract.json index 89be868233..6279babbe5 100644 --- a/cli/contract.json +++ b/cli/contract.json @@ -1,4 +1,4 @@ { "schemaVersion": 1, - "cliVersion": "3.0.0-beta.23" + "cliVersion": "3.0.0-beta.24" } diff --git a/cli/internal/tools/default-tools.json b/cli/internal/tools/default-tools.json index 5463bdcfb9..904923ef8f 100644 --- a/cli/internal/tools/default-tools.json +++ b/cli/internal/tools/default-tools.json @@ -1,5 +1,5 @@ { - "version": "3.0.0-beta.23", + "version": "3.0.0-beta.24", "tools": [ { "name": "compile",