diff --git a/Assets/Tests/Editor/HotReload/HotReloadIncrementalSnapshotCaptureTests.cs b/Assets/Tests/Editor/HotReload/HotReloadIncrementalSnapshotCaptureTests.cs
new file mode 100644
index 000000000..e054e1806
--- /dev/null
+++ b/Assets/Tests/Editor/HotReload/HotReloadIncrementalSnapshotCaptureTests.cs
@@ -0,0 +1,726 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Runtime.InteropServices;
+using System.Text;
+using System.Text.RegularExpressions;
+
+using NUnit.Framework;
+
+using UnityEditor.Compilation;
+
+using UnityEngine;
+using UnityEngine.TestTools;
+
+using io.github.hatayama.UnityCliLoop.FirstPartyTools;
+
+using UnityCompilationAssembly = UnityEditor.Compilation.Assembly;
+
+namespace io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload
+{
+ ///
+ /// EditMode coverage for the capture that reuses the previous build's snapshot directory and
+ /// rewrites only the sources that changed, run in a per-test temporary project root. A previous
+ /// copy holds a sentinel that no source holds, so a copy that still holds it was reused and a
+ /// copy that holds the source's bytes was written again.
+ ///
+ public sealed class HotReloadIncrementalSnapshotCaptureTests
+ {
+ private const string AssemblyName = "Fixture";
+ private const string TestAssemblyName = "UnityCLILoop.Tests.Editor.HotReload";
+ private const string PausePointsRuntimeAssemblyName = "UnityCLILoop.PausePoints.Runtime";
+ private const string FixtureProjectRelativePath = "Assets/Tests/Editor/HotReload/HotReloadE2EFixtures.cs";
+ private const string Sentinel = "PREVIOUS";
+ private const uint RestoredDirectoryMode = 0x1ED; // 0755
+ private const uint RestoredFileMode = 0x1A4; // 0644
+
+ private static readonly DateTime BeforeTheCompileUtc = new DateTime(2020, 1, 1, 0, 0, 0, DateTimeKind.Utc);
+ private static readonly DateTime CompileStartUtc = new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc);
+ private static readonly DateTime SinceTheCompileUtc = new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc);
+
+ private string _root;
+ private string _previousDirectory;
+ private string _newDirectory;
+ private readonly List _restoreDirectoryModes = new List();
+ private readonly List _restoreFileModes = new List();
+
+ [SetUp]
+ public void SetUp()
+ {
+ _root = Path.Combine(Path.GetTempPath(), "uloop-test-" + Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(Path.Combine(_root, "Assets"));
+ Directory.CreateDirectory(Path.Combine(_root, HotReloadConstants.ScriptAssembliesRelativeDirectory));
+ Directory.CreateDirectory(HotReloadSourceSnapshotLayout.Root(_root));
+ _previousDirectory = SnapshotDirectory(Guid.NewGuid().ToString("N"));
+ _newDirectory = SnapshotDirectory(Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(_previousDirectory);
+ }
+
+ [TearDown]
+ public void TearDown()
+ {
+ // Why the modes come back first: a directory without write permission cannot be emptied.
+ foreach (string path in _restoreDirectoryModes)
+ {
+ Chmod(path, RestoredDirectoryMode);
+ }
+
+ foreach (string path in _restoreFileModes)
+ {
+ Chmod(path, RestoredFileMode);
+ }
+
+ _restoreDirectoryModes.Clear();
+ _restoreFileModes.Clear();
+ if (Directory.Exists(_root))
+ {
+ Directory.Delete(_root, true);
+ }
+ }
+
+ ///
+ /// Verifies a source with an unmarked line whose length and write time still match, written
+ /// before the compile started, keeps the previous copy and its line.
+ ///
+ [Test]
+ public void CaptureIncrementally_UnchangedTrustedSource_ReusesThePreviousCopy()
+ {
+ WriteSource("Assets/A.cs", "class A {}\n", BeforeTheCompileUtc);
+ PlantPreviousCopy("Assets/A.cs");
+ WritePreviousManifest(CurrentLine("Assets/A.cs", false));
+
+ CaptureIncrementally(SuspectsNothing(), "Assets/A.cs");
+
+ Assert.That(NewCopyText("Assets/A.cs"), Is.EqualTo(Sentinel));
+ AssertNewLine("Assets/A.cs", false);
+ }
+
+ ///
+ /// Verifies a source whose length or write time differs from its line is copied again.
+ ///
+ [TestCase(1, 0)]
+ [TestCase(0, 1)]
+ public void CaptureIncrementally_SourceWhoseLengthOrWriteTimeChanged_CopiesTheCurrentBytes(
+ int lineLengthOffset,
+ int lineSecondsOffset)
+ {
+ WriteSource("Assets/A.cs", "class A {}\n", BeforeTheCompileUtc);
+ PlantPreviousCopy("Assets/A.cs");
+ FileInfo source = SourceInfo("Assets/A.cs");
+ WritePreviousManifest(HotReloadSourceStampManifest.FormatLine(
+ SnapshotFileName("Assets/A.cs"),
+ source.Length + lineLengthOffset,
+ source.LastWriteTimeUtc.AddSeconds(-lineSecondsOffset).Ticks,
+ false));
+
+ CaptureIncrementally(SuspectsNothing(), "Assets/A.cs");
+
+ Assert.That(NewCopyText("Assets/A.cs"), Is.EqualTo("class A {}\n"));
+ AssertNewLine("Assets/A.cs", false);
+ }
+
+ ///
+ /// Verifies a source whose line marks it as edited after the compile is copied again, even
+ /// though its length and write time match.
+ ///
+ [Test]
+ public void CaptureIncrementally_SourceMarkedEditedAfterCompile_CopiesTheCurrentBytes()
+ {
+ WriteSource("Assets/A.cs", "class A {}\n", BeforeTheCompileUtc);
+ PlantPreviousCopy("Assets/A.cs");
+ WritePreviousManifest(CurrentLine("Assets/A.cs", true));
+
+ CaptureIncrementally(SuspectsNothing(), "Assets/A.cs");
+
+ Assert.That(NewCopyText("Assets/A.cs"), Is.EqualTo("class A {}\n"));
+ AssertNewLine("Assets/A.cs", false);
+ }
+
+ ///
+ /// Verifies a source the previous manifest has no line for is copied again, while a source
+ /// beside it that has one is reused.
+ ///
+ [Test]
+ public void CaptureIncrementally_SourceWithoutAManifestLine_CopiesTheCurrentBytes()
+ {
+ WriteSource("Assets/A.cs", "class A {}\n", BeforeTheCompileUtc);
+ WriteSource("Assets/B.cs", "class B {}\n", BeforeTheCompileUtc);
+ PlantPreviousCopy("Assets/A.cs");
+ PlantPreviousCopy("Assets/B.cs");
+ WritePreviousManifest(CurrentLine("Assets/A.cs", false));
+
+ CaptureIncrementally(SuspectsNothing(), "Assets/A.cs", "Assets/B.cs");
+
+ Assert.That(NewCopyText("Assets/A.cs"), Is.EqualTo(Sentinel), "A is reused");
+ Assert.That(NewCopyText("Assets/B.cs"), Is.EqualTo("class B {}\n"), "B is copied");
+ AssertNewLine("Assets/B.cs", false);
+ }
+
+ ///
+ /// Verifies a source the previous build did not have is copied and gets a line.
+ ///
+ [Test]
+ public void CaptureIncrementally_SourceNewToTheAssembly_CopiesIt()
+ {
+ WriteSource("Assets/A.cs", "class A {}\n", BeforeTheCompileUtc);
+ WriteSource("Assets/New.cs", "class New {}\n", BeforeTheCompileUtc);
+ PlantPreviousCopy("Assets/A.cs");
+ WritePreviousManifest(CurrentLine("Assets/A.cs", false));
+
+ CaptureIncrementally(SuspectsNothing(), "Assets/A.cs", "Assets/New.cs");
+
+ Assert.That(NewCopyText("Assets/New.cs"), Is.EqualTo("class New {}\n"));
+ AssertNewLine("Assets/New.cs", false);
+ }
+
+ ///
+ /// Verifies a source whose unmarked line still matches is copied again when the previous
+ /// directory has no copy of it.
+ ///
+ [Test]
+ public void CaptureIncrementally_PreviousCopyFileMissing_CopiesTheCurrentBytes()
+ {
+ WriteSource("Assets/A.cs", "class A {}\n", BeforeTheCompileUtc);
+ WritePreviousManifest(CurrentLine("Assets/A.cs", false));
+
+ CaptureIncrementally(SuspectsNothing(), "Assets/A.cs");
+
+ Assert.That(NewCopyText("Assets/A.cs"), Is.EqualTo("class A {}\n"));
+ AssertNewLine("Assets/A.cs", false);
+ }
+
+ ///
+ /// Verifies a source whose unmarked line still matches, but whose write time falls inside the
+ /// new compile's suspect window, is copied again and checked against the new PDB.
+ ///
+ [Test]
+ public void CaptureIncrementally_SourceWrittenInsideTheNewSuspectWindow_IsCopiedAndCheckedAgain()
+ {
+ byte[] realBytes = File.ReadAllBytes(Path.Combine(RealProjectRoot(), FixtureProjectRelativePath));
+ WriteSourceBytes(FixtureProjectRelativePath, realBytes, SinceTheCompileUtc);
+ PlantPreviousCopy(FixtureProjectRelativePath);
+ WritePreviousManifest(CurrentLine(FixtureProjectRelativePath, false));
+ string dllPath = RealDllPath(TestAssemblyName);
+
+ HotReloadSnapshotCaptureStats stats = CaptureIncrementally(
+ CheckAgainst(dllPath, Path.ChangeExtension(dllPath, ".pdb")),
+ FixtureProjectRelativePath);
+
+ Assert.That(File.ReadAllBytes(NewCopyPath(FixtureProjectRelativePath)), Is.EqualTo(realBytes));
+ Assert.That(stats.FilesChecked, Is.EqualTo(1));
+ AssertNewLine(FixtureProjectRelativePath, false);
+ }
+
+ ///
+ /// Verifies the copy of a source that is no longer in the assembly does not reach the new
+ /// directory.
+ ///
+ [Test]
+ public void CaptureIncrementally_SourceRemovedFromTheAssembly_DeletesItsCopy()
+ {
+ WriteSource("Assets/A.cs", "class A {}\n", BeforeTheCompileUtc);
+ WriteSource("Assets/Removed.cs", "class Removed {}\n", BeforeTheCompileUtc);
+ PlantPreviousCopy("Assets/A.cs");
+ PlantPreviousCopy("Assets/Removed.cs");
+ WritePreviousManifest(CurrentLine("Assets/A.cs", false), CurrentLine("Assets/Removed.cs", false));
+
+ CaptureIncrementally(SuspectsNothing(), "Assets/A.cs");
+
+ Assert.That(File.Exists(NewCopyPath("Assets/Removed.cs")), Is.False);
+ Assert.That(NewManifest().TryGetStamp(SnapshotFileName("Assets/Removed.cs"), out long _, out long _), Is.False);
+ }
+
+ ///
+ /// Verifies a listed source that no longer exists leaves neither its previous copy nor a line.
+ ///
+ [Test]
+ public void CaptureIncrementally_SourceMissingNow_LeavesNoCopyAndNoLine()
+ {
+ WriteSource("Assets/A.cs", "class A {}\n", BeforeTheCompileUtc);
+ WriteSource("Assets/Gone.cs", "class Gone {}\n", BeforeTheCompileUtc);
+ PlantPreviousCopy("Assets/A.cs");
+ PlantPreviousCopy("Assets/Gone.cs");
+ WritePreviousManifest(CurrentLine("Assets/A.cs", false), CurrentLine("Assets/Gone.cs", false));
+ File.Delete(SourcePath("Assets/Gone.cs"));
+
+ CaptureIncrementally(SuspectsNothing(), "Assets/A.cs", "Assets/Gone.cs");
+
+ Assert.That(File.Exists(NewCopyPath("Assets/Gone.cs")), Is.False);
+ Assert.That(NewManifest().TryGetStamp(SnapshotFileName("Assets/Gone.cs"), out long _, out long _), Is.False);
+ }
+
+ ///
+ /// Verifies a capture whose stamp names the previous build's complete directory publishes the
+ /// snapshot under the new MVID by moving that directory, so the reused copy is there, the
+ /// previous directory is gone, and the stamp names the new MVID.
+ ///
+ [Test]
+ public void CaptureIncrementally_PublishesUnderTheNewMvidAndThePreviousDirectoryIsGone()
+ {
+ string dllPath = PlantCompiledAssembly();
+ string mvid = HotReloadAssemblyMvid.Read(dllPath);
+ WriteSource("Assets/A.cs", "class A {}\n", BeforeTheCompileUtc);
+ PlantPreviousCopy("Assets/A.cs");
+ WritePreviousManifest(CurrentLine("Assets/A.cs", false));
+ WriteStampNamingPrevious();
+
+ CaptureAssemblies("Assets/A.cs");
+
+ string publishedDirectory = SnapshotDirectory(mvid);
+ Assert.That(
+ File.ReadAllText(HotReloadSourceSnapshotLayout.SourcePath(publishedDirectory, "Assets/A.cs")),
+ Is.EqualTo(Sentinel));
+ Assert.That(Directory.Exists(_previousDirectory), Is.False);
+ Assert.That(File.ReadAllText(StampPath()).Split(',')[0], Is.EqualTo(mvid));
+ }
+
+ ///
+ /// Verifies a temporary directory left over under the new name is deleted before the previous
+ /// directory is moved there, so the capture still publishes and the leftover file is gone.
+ ///
+ [Test]
+ public void CaptureIncrementally_WhenAnIncompleteDirectoryIsLeftOver_DeletesItFirst()
+ {
+ WriteSource("Assets/A.cs", "class A {}\n", BeforeTheCompileUtc);
+ PlantPreviousCopy("Assets/A.cs");
+ WritePreviousManifest(CurrentLine("Assets/A.cs", false));
+ string leftover = _newDirectory + HotReloadSourceSnapshotLayout.IncompleteDirectorySuffix;
+ Directory.CreateDirectory(leftover);
+ File.WriteAllText(Path.Combine(leftover, "stale.cs"), "stale\n");
+
+ CaptureIncrementally(SuspectsNothing(), "Assets/A.cs");
+
+ Assert.That(NewCopyText("Assets/A.cs"), Is.EqualTo(Sentinel));
+ Assert.That(File.Exists(Path.Combine(_newDirectory, "stale.cs")), Is.False);
+ Assert.That(Directory.Exists(leftover), Is.False);
+ }
+
+ ///
+ /// Verifies that when the previous directory cannot be moved, the capture copies every source
+ /// into the new directory instead and leaves the previous directory where it was.
+ ///
+ [Test]
+ [UnityPlatform(exclude = new[] { RuntimePlatform.WindowsEditor })]
+ public void CaptureIncrementally_WhenThePreviousDirectoryCannotBeMoved_FallsBackToAFullCopy()
+ {
+ IgnoreWhenRoot();
+ WriteSource("Assets/A.cs", "class A {}\n", BeforeTheCompileUtc);
+ string lockedParent = Path.Combine(_root, "Locked");
+ string previousDirectory = Path.Combine(lockedParent, AssemblyName + "-" + Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(previousDirectory);
+ File.WriteAllText(HotReloadSourceSnapshotLayout.SourcePath(previousDirectory, "Assets/A.cs"), Sentinel);
+ HotReloadSourceStampManifest.Write(previousDirectory, new[] { CurrentLine("Assets/A.cs", false) });
+ ChangeDirectoryMode(lockedParent, 0x16D); // 0555
+
+ HotReloadSourceSnapshotCopier.CaptureIncrementally(
+ _root,
+ _newDirectory,
+ previousDirectory,
+ new[] { "Assets/A.cs" },
+ AssemblyName,
+ SuspectsNothing(),
+ new HotReloadSnapshotCaptureStats());
+
+ Assert.That(NewCopyText("Assets/A.cs"), Is.EqualTo("class A {}\n"));
+ Assert.That(
+ File.ReadAllText(HotReloadSourceSnapshotLayout.SourcePath(previousDirectory, "Assets/A.cs")),
+ Is.EqualTo(Sentinel));
+ }
+
+ ///
+ /// Verifies a stamp that names no usable previous snapshot makes the capture copy every source:
+ /// a first field that is not an MVID, an MVID with no directory, a directory with no manifest,
+ /// and a manifest with an older header.
+ ///
+ [TestCase("not_an_mvid")]
+ [TestCase("no_directory")]
+ [TestCase("no_manifest")]
+ [TestCase("old_header")]
+ public void CaptureAssemblies_WhenTheStampNamesAnUnusablePreviousDirectory_CopiesEverything(string unusable)
+ {
+ string dllPath = PlantCompiledAssembly();
+ string mvid = HotReloadAssemblyMvid.Read(dllPath);
+ WriteSource("Assets/A.cs", "class A {}\n", BeforeTheCompileUtc);
+ PlantPreviousCopy("Assets/A.cs");
+ string line = CurrentLine("Assets/A.cs", false);
+ string manifestPath = Path.Combine(_previousDirectory, HotReloadConstants.SourceStampManifestFileName);
+ switch (unusable)
+ {
+ case "not_an_mvid":
+ WritePreviousManifest(line);
+ File.WriteAllText(StampPath(), "immutable,0,0");
+ break;
+ case "no_directory":
+ WritePreviousManifest(line);
+ File.WriteAllText(StampPath(), Guid.NewGuid().ToString("N") + ",0,0");
+ break;
+ case "no_manifest":
+ WriteStampNamingPrevious();
+ break;
+ case "old_header":
+ File.WriteAllText(manifestPath, "uloop-source-stamps 1\n" + line + "\n");
+ WriteStampNamingPrevious();
+ break;
+ default:
+ Assert.Fail("Unknown case " + unusable);
+ break;
+ }
+
+ CaptureAssemblies("Assets/A.cs");
+
+ Assert.That(
+ File.ReadAllText(HotReloadSourceSnapshotLayout.SourcePath(SnapshotDirectory(mvid), "Assets/A.cs")),
+ Is.EqualTo("class A {}\n"));
+ }
+
+ ///
+ /// Verifies that when the incremental snapshot cannot be published, the capture warns, the
+ /// previous directory is gone and the stamp still names it, and the next capture copies every
+ /// source.
+ ///
+ [Test]
+ public void CaptureAssemblies_WhenPublishingAnIncrementalSnapshotFails_TheNextCaptureCopiesEverything()
+ {
+ string dllPath = PlantCompiledAssembly();
+ string mvid = HotReloadAssemblyMvid.Read(dllPath);
+ WriteSource("Assets/A.cs", "class A {}\n", BeforeTheCompileUtc);
+ PlantPreviousCopy("Assets/A.cs");
+ WritePreviousManifest(CurrentLine("Assets/A.cs", false));
+ WriteStampNamingPrevious();
+ string stampBefore = File.ReadAllText(StampPath());
+ string publishedDirectory = SnapshotDirectory(mvid);
+ // A file where the published directory goes makes the final move fail.
+ File.WriteAllText(publishedDirectory, "blocker");
+ LogAssert.Expect(LogType.Warning, new Regex("Snapshot capture failed for assembly " + AssemblyName));
+
+ CaptureAssemblies("Assets/A.cs");
+
+ Assert.That(Directory.Exists(_previousDirectory), Is.False, "previous directory");
+ Assert.That(File.ReadAllText(StampPath()), Is.EqualTo(stampBefore), "stamp");
+
+ File.Delete(publishedDirectory);
+ CaptureAssemblies("Assets/A.cs");
+
+ Assert.That(
+ File.ReadAllText(HotReloadSourceSnapshotLayout.SourcePath(publishedDirectory, "Assets/A.cs")),
+ Is.EqualTo("class A {}\n"));
+ }
+
+ ///
+ /// Verifies the capture reports the reused and the copied files: of three sources, the one that
+ /// changed is copied and the other two are reused.
+ ///
+ [Test]
+ public void CaptureAssemblies_ReportsReusedAndCopiedFiles()
+ {
+ PlantCompiledAssembly();
+ string[] sources = { "Assets/A.cs", "Assets/B.cs", "Assets/C.cs" };
+ List lines = new List();
+ foreach (string source in sources)
+ {
+ WriteSource(source, "class X {}\n", BeforeTheCompileUtc);
+ PlantPreviousCopy(source);
+ lines.Add(CurrentLine(source, false));
+ }
+
+ WritePreviousManifest(lines.ToArray());
+ WriteStampNamingPrevious();
+ WriteSource("Assets/B.cs", "class Changed {}\n", BeforeTheCompileUtc.AddSeconds(1));
+
+ HotReloadSnapshotCaptureStats stats = CaptureAssemblies(sources);
+
+ Assert.That(stats.FilesReused, Is.EqualTo(2), "reused");
+ Assert.That(stats.FilesCopied, Is.EqualTo(1), "copied");
+ }
+
+ ///
+ /// Verifies a source that cannot be read but whose unmarked line still matches is reused
+ /// without being read, keeping its line and raising no warning.
+ ///
+ [Test]
+ [UnityPlatform(exclude = new[] { RuntimePlatform.WindowsEditor })]
+ public void CaptureIncrementally_UnreadableSourceWhoseStampIsUnchanged_ReusesThePreviousCopyWithoutReadingIt()
+ {
+ WriteSource("Assets/A.cs", "class A {}\n", BeforeTheCompileUtc);
+ PlantPreviousCopy("Assets/A.cs");
+ WritePreviousManifest(CurrentLine("Assets/A.cs", false));
+ ChangeFileMode(SourcePath("Assets/A.cs"), 0);
+
+ CaptureIncrementally(SuspectsNothing(), "Assets/A.cs");
+
+ Assert.That(NewCopyText("Assets/A.cs"), Is.EqualTo(Sentinel));
+ AssertNewLine("Assets/A.cs", false);
+ LogAssert.NoUnexpectedReceived();
+ }
+
+ ///
+ /// Verifies a source that changed since its line and cannot be read leaves neither the previous
+ /// copy nor a line, and the capture warns about it.
+ ///
+ [Test]
+ [UnityPlatform(exclude = new[] { RuntimePlatform.WindowsEditor })]
+ public void CaptureIncrementally_UnreadableSourceThatChanged_LeavesNoCopyAndNoLineAndWarns()
+ {
+ IgnoreWhenRoot();
+ WriteSource("Assets/A.cs", "class A {}\n", BeforeTheCompileUtc);
+ PlantPreviousCopy("Assets/A.cs");
+ WritePreviousManifest(CurrentLine("Assets/A.cs", false));
+ File.SetLastWriteTimeUtc(SourcePath("Assets/A.cs"), BeforeTheCompileUtc.AddSeconds(1));
+ ChangeFileMode(SourcePath("Assets/A.cs"), 0);
+ LogAssert.Expect(LogType.Warning, new Regex("Skipped 1 unreadable source"));
+
+ CaptureIncrementally(SuspectsNothing(), "Assets/A.cs");
+
+ Assert.That(File.Exists(NewCopyPath("Assets/A.cs")), Is.False);
+ Assert.That(NewManifest().TryGetStamp(SnapshotFileName("Assets/A.cs"), out long _, out long _), Is.False);
+ }
+
+ ///
+ /// Verifies that when the PDB check throws after the previous directory was moved to the new
+ /// temporary directory, the exception reaches the caller and nothing is published: the
+ /// previous directory now sits under the temporary name with the copy reused before the throw.
+ ///
+ [Test]
+ public void CaptureIncrementally_WhenTheCheckThrowsAfterAdoptingThePreviousDirectory_PublishesNothing()
+ {
+ string mismatchedDirectory = Path.Combine(_root, "Mismatched");
+ Directory.CreateDirectory(mismatchedDirectory);
+ string dllPath = Path.Combine(mismatchedDirectory, "X.dll");
+ string pdbPath = Path.Combine(mismatchedDirectory, "X.pdb");
+ File.Copy(RealDllPath(TestAssemblyName), dllPath);
+ File.Copy(Path.ChangeExtension(RealDllPath(PausePointsRuntimeAssemblyName), ".pdb"), pdbPath);
+ WriteSource("Assets/Reused.cs", "class Reused {}\n", BeforeTheCompileUtc);
+ WriteSource("Assets/Suspect.cs", "class Suspect {}\n", SinceTheCompileUtc);
+ PlantPreviousCopy("Assets/Reused.cs");
+ WritePreviousManifest(CurrentLine("Assets/Reused.cs", false));
+
+ Assert.Throws(() => HotReloadSourceSnapshotCopier.CaptureIncrementally(
+ _root,
+ _newDirectory,
+ _previousDirectory,
+ new[] { "Assets/Reused.cs", "Assets/Suspect.cs" },
+ AssemblyName,
+ CheckAgainst(dllPath, pdbPath),
+ new HotReloadSnapshotCaptureStats()));
+
+ string temporaryDirectory = _newDirectory + HotReloadSourceSnapshotLayout.IncompleteDirectorySuffix;
+ Assert.That(Directory.Exists(_newDirectory), Is.False, "published directory");
+ Assert.That(Directory.Exists(_previousDirectory), Is.False, "previous directory");
+ Assert.That(
+ File.ReadAllText(HotReloadSourceSnapshotLayout.SourcePath(temporaryDirectory, "Assets/Reused.cs")),
+ Is.EqualTo(Sentinel));
+ }
+
+ private HotReloadSnapshotCaptureStats CaptureIncrementally(HotReloadSnapshotSourceCheck check, params string[] sources)
+ {
+ HotReloadSnapshotCaptureStats stats = new HotReloadSnapshotCaptureStats();
+ HotReloadSourceSnapshotCopier.CaptureIncrementally(
+ _root,
+ _newDirectory,
+ _previousDirectory,
+ sources,
+ AssemblyName,
+ check,
+ stats);
+ return stats;
+ }
+
+ private HotReloadSnapshotCaptureStats CaptureAssemblies(params string[] sources)
+ {
+ UnityCompilationAssembly assembly = new UnityCompilationAssembly(
+ AssemblyName,
+ HotReloadConstants.ScriptAssembliesRelativeDirectory + "/" + AssemblyName + HotReloadConstants.CompiledAssemblyExtension,
+ sources,
+ Array.Empty(),
+ Array.Empty(),
+ Array.Empty(),
+ AssemblyFlags.EditorAssembly);
+ return HotReloadSourceSnapshotter.CaptureAssemblies(
+ _root,
+ new[] { assembly },
+ HotReloadCompileStart.Unknown,
+ new HotReloadPdbDocumentIndex(Path.Combine(_root, "PdbDocuments")));
+ }
+
+ // A check whose suspect window starts at the planted compile start, against the given build.
+ private HotReloadSnapshotSourceCheck CheckAgainst(string dllPath, string pdbPath)
+ {
+ return new HotReloadSnapshotSourceCheck(
+ CompileStartUtc.Ticks,
+ dllPath,
+ pdbPath,
+ HotReloadAssemblyMvid.Read(dllPath),
+ new HotReloadPdbDocumentIndex(Path.Combine(_root, "PdbDocuments")));
+ }
+
+ // A check whose suspect window never opens, so no copy is checked against a PDB.
+ private HotReloadSnapshotSourceCheck SuspectsNothing()
+ {
+ return new HotReloadSnapshotSourceCheck(
+ DateTime.MaxValue.Ticks,
+ "unused.dll",
+ "unused.pdb",
+ "unused",
+ new HotReloadPdbDocumentIndex(Path.Combine(_root, "PdbDocuments")));
+ }
+
+ // Copies this test assembly's own DLL and PDB, so the fixture has a real image for Cecil to read.
+ private string PlantCompiledAssembly()
+ {
+ string dllPath = Path.Combine(
+ _root,
+ HotReloadConstants.ScriptAssembliesRelativeDirectory,
+ AssemblyName + HotReloadConstants.CompiledAssemblyExtension);
+ File.Copy(RealDllPath(TestAssemblyName), dllPath);
+ File.Copy(Path.ChangeExtension(RealDllPath(TestAssemblyName), ".pdb"), Path.ChangeExtension(dllPath, ".pdb"));
+ return dllPath;
+ }
+
+ // Why an mtime and length no DLL has: the stamp must not match, or the capture stops there.
+ private void WriteStampNamingPrevious()
+ {
+ string previousMvid = Path.GetFileName(_previousDirectory).Substring(AssemblyName.Length + 1);
+ File.WriteAllText(StampPath(), previousMvid + ",0,0");
+ }
+
+ private void WriteSource(string projectRelativePath, string text, DateTime lastWriteTimeUtc)
+ {
+ WriteSourceBytes(projectRelativePath, Encoding.ASCII.GetBytes(text), lastWriteTimeUtc);
+ }
+
+ // Why a write time in whole seconds: SetLastWriteTimeUtc stores microseconds while
+ // LastWriteTimeUtc reports 100 ns ticks, so only such a value is read back exactly.
+ private void WriteSourceBytes(string projectRelativePath, byte[] bytes, DateTime lastWriteTimeUtc)
+ {
+ string path = SourcePath(projectRelativePath);
+ Directory.CreateDirectory(Path.GetDirectoryName(path));
+ File.WriteAllBytes(path, bytes);
+ File.SetLastWriteTimeUtc(path, lastWriteTimeUtc);
+ Assert.That(new FileInfo(path).LastWriteTimeUtc, Is.EqualTo(lastWriteTimeUtc), "write time of " + projectRelativePath);
+ }
+
+ private void PlantPreviousCopy(string projectRelativePath)
+ {
+ File.WriteAllText(HotReloadSourceSnapshotLayout.SourcePath(_previousDirectory, projectRelativePath), Sentinel);
+ }
+
+ private void WritePreviousManifest(params string[] lines)
+ {
+ HotReloadSourceStampManifest.Write(_previousDirectory, lines);
+ }
+
+ // The line the previous capture would have written for the source as it is now.
+ private string CurrentLine(string projectRelativePath, bool editedAfterCompile)
+ {
+ FileInfo source = SourceInfo(projectRelativePath);
+ return HotReloadSourceStampManifest.FormatLine(
+ SnapshotFileName(projectRelativePath),
+ source.Length,
+ source.LastWriteTimeUtc.Ticks,
+ editedAfterCompile);
+ }
+
+ private void AssertNewLine(string projectRelativePath, bool editedAfterCompile)
+ {
+ HotReloadSourceStampManifest manifest = NewManifest();
+ FileInfo source = SourceInfo(projectRelativePath);
+ Assert.That(
+ manifest.TryGetStamp(SnapshotFileName(projectRelativePath), out long length, out long ticks),
+ Is.True,
+ "line of " + projectRelativePath);
+ Assert.That(length, Is.EqualTo(source.Length), "length of " + projectRelativePath);
+ Assert.That(ticks, Is.EqualTo(source.LastWriteTimeUtc.Ticks), "write time of " + projectRelativePath);
+ Assert.That(manifest.IsEditedAfterCompile(SnapshotFileName(projectRelativePath)), Is.EqualTo(editedAfterCompile));
+ }
+
+ private HotReloadSourceStampManifest NewManifest()
+ {
+ return HotReloadSourceStampManifest.Load(_newDirectory);
+ }
+
+ private string NewCopyText(string projectRelativePath)
+ {
+ return File.ReadAllText(NewCopyPath(projectRelativePath));
+ }
+
+ private string NewCopyPath(string projectRelativePath)
+ {
+ return HotReloadSourceSnapshotLayout.SourcePath(_newDirectory, projectRelativePath);
+ }
+
+ private FileInfo SourceInfo(string projectRelativePath)
+ {
+ return new FileInfo(SourcePath(projectRelativePath));
+ }
+
+ private string SourcePath(string projectRelativePath)
+ {
+ return Path.Combine(_root, projectRelativePath.Replace('/', Path.DirectorySeparatorChar));
+ }
+
+ private string SnapshotDirectory(string mvid)
+ {
+ return HotReloadSourceSnapshotLayout.AssemblyDirectory(_root, AssemblyName, mvid);
+ }
+
+ private string StampPath()
+ {
+ return Path.Combine(HotReloadSourceSnapshotLayout.Root(_root), AssemblyName + ".stamp");
+ }
+
+ private static string SnapshotFileName(string projectRelativePath)
+ {
+ return HotReloadSourceSnapshotLayout.SourceFileName(projectRelativePath);
+ }
+
+ private static string RealProjectRoot()
+ {
+ return Path.GetFullPath(Path.Combine(Application.dataPath, ".."));
+ }
+
+ private static string RealDllPath(string assemblyName)
+ {
+ return Path.Combine(
+ RealProjectRoot(),
+ HotReloadConstants.ScriptAssembliesRelativeDirectory,
+ assemblyName + HotReloadConstants.CompiledAssemblyExtension);
+ }
+
+ private void ChangeDirectoryMode(string path, uint mode)
+ {
+ _restoreDirectoryModes.Add(path);
+ ChangeMode(path, mode);
+ }
+
+ private void ChangeFileMode(string path, uint mode)
+ {
+ _restoreFileModes.Add(path);
+ ChangeMode(path, mode);
+ }
+
+ private static void ChangeMode(string path, uint mode)
+ {
+ if (Chmod(path, mode) != 0)
+ {
+ Assert.Fail("chmod failed for " + path + ": errno " + Marshal.GetLastWin32Error());
+ }
+ }
+
+ // Why: the nightly EditMode run is root inside its container, and root ignores permission
+ // bits, so a mode that stops this user stops nothing there. Ignore, not Assume: batchmode
+ // -runTests exits with 2 on an inconclusive test even when nothing failed.
+ private static void IgnoreWhenRoot()
+ {
+ if (GetEffectiveUserId() == 0)
+ {
+ Assert.Ignore("Permission bits do not stop root.");
+ }
+ }
+
+ [DllImport("libc", EntryPoint = "chmod", SetLastError = true)]
+ private static extern int Chmod(string path, uint mode);
+
+ [DllImport("libc", EntryPoint = "geteuid", SetLastError = true)]
+ private static extern uint GetEffectiveUserId();
+ }
+}
diff --git a/Assets/Tests/Editor/HotReload/HotReloadIncrementalSnapshotCaptureTests.cs.meta b/Assets/Tests/Editor/HotReload/HotReloadIncrementalSnapshotCaptureTests.cs.meta
new file mode 100644
index 000000000..cca725a36
--- /dev/null
+++ b/Assets/Tests/Editor/HotReload/HotReloadIncrementalSnapshotCaptureTests.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: bc36733be484d452587aa5ea99513875
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSnapshotCaptureStats.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSnapshotCaptureStats.cs
index 1097c0cd4..3f939b7bf 100644
--- a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSnapshotCaptureStats.cs
+++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSnapshotCaptureStats.cs
@@ -22,6 +22,7 @@ internal sealed class HotReloadSnapshotCaptureStats
internal int AssembliesImmutable;
internal int AssembliesUnchanged;
internal int AssembliesCaptured;
+ internal int FilesReused;
internal int FilesCopied;
internal long BytesCopied;
internal int FilesChecked;
@@ -44,6 +45,7 @@ internal object ToVibeLogContext(long getAssembliesMs, long totalMs)
assembliesImmutable = AssembliesImmutable,
assembliesUnchanged = AssembliesUnchanged,
assembliesCaptured = AssembliesCaptured,
+ filesReused = FilesReused,
filesCopied = FilesCopied,
bytesCopied = BytesCopied,
filesChecked = FilesChecked
diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceSnapshotCopier.Incremental.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceSnapshotCopier.Incremental.cs
new file mode 100644
index 000000000..62db46410
--- /dev/null
+++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceSnapshotCopier.Incremental.cs
@@ -0,0 +1,190 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+
+using UnityEngine;
+
+namespace io.github.hatayama.UnityCliLoop.FirstPartyTools
+{
+ // Builds an assembly's new snapshot from the previous build's: it moves the previous directory to
+ // the new temporary name, keeps the copies whose sources have not changed since, and writes only
+ // the rest again.
+ internal static partial class HotReloadSourceSnapshotCopier
+ {
+ internal static void CaptureIncrementally(
+ string projectRoot,
+ string assemblySnapshotDirectory,
+ string previousSnapshotDirectory,
+ string[] sourceFiles,
+ string assemblyName,
+ HotReloadSnapshotSourceCheck check,
+ HotReloadSnapshotCaptureStats stats)
+ {
+ Debug.Assert(stats != null, "stats must not be null.");
+
+ string temporaryDirectory = assemblySnapshotDirectory + HotReloadSourceSnapshotLayout.IncompleteDirectorySuffix;
+ System.Diagnostics.Stopwatch adoptWatch = System.Diagnostics.Stopwatch.StartNew();
+ // Why the leftover goes first: moving the previous directory onto an existing one throws.
+ if (Directory.Exists(temporaryDirectory))
+ {
+ Directory.Delete(temporaryDirectory, recursive: true);
+ }
+
+ stats.PublishTicks += adoptWatch.Elapsed.Ticks;
+ System.Diagnostics.Stopwatch manifestWatch = System.Diagnostics.Stopwatch.StartNew();
+ // Read before the move: the manifest moves with the directory and is rewritten there.
+ HotReloadSourceStampManifest previousManifest = HotReloadSourceStampManifest.Load(previousSnapshotDirectory);
+ stats.StampTicks += manifestWatch.Elapsed.Ticks;
+ adoptWatch.Restart();
+ bool adopted = TryAdoptPreviousSnapshot(previousSnapshotDirectory, temporaryDirectory);
+ stats.PublishTicks += adoptWatch.Elapsed.Ticks;
+ if (!adopted)
+ {
+ CaptureAtomically(projectRoot, assemblySnapshotDirectory, sourceFiles, assemblyName, check, stats);
+ return;
+ }
+
+ HashSet listedNames = new HashSet(StringComparer.Ordinal);
+ SourceCopyTally tally = new SourceCopyTally();
+ List manifestLines = new List(sourceFiles.Length);
+ foreach (string projectRelativeSourcePath in sourceFiles)
+ {
+ string normalizedRelativePath = projectRelativeSourcePath.Replace('\\', '/');
+ string snapshotFileName = HotReloadSourceSnapshotLayout.SourceFileName(normalizedRelativePath);
+ listedNames.Add(snapshotFileName);
+ string copyPath = Path.Combine(temporaryDirectory, snapshotFileName);
+ if (TryReusePreviousCopy(
+ projectRoot,
+ normalizedRelativePath,
+ snapshotFileName,
+ copyPath,
+ previousManifest,
+ check.SuspectWritesFromUtcTicks,
+ manifestLines,
+ stats))
+ {
+ stats.FilesReused++;
+ continue;
+ }
+
+ // Why the previous copy goes before the copy: a source that is missing or unreadable now
+ // gets no copy, and a stale copy left in its place would make readers that compare bytes
+ // see a change that is not there.
+ System.Diagnostics.Stopwatch deleteWatch = System.Diagnostics.Stopwatch.StartNew();
+ File.Delete(copyPath);
+ stats.ReadWriteTicks += deleteWatch.Elapsed.Ticks;
+ SourceCopyOutcome outcome = CopySourceFileByteExact(
+ projectRoot,
+ temporaryDirectory,
+ projectRelativeSourcePath,
+ check,
+ manifestLines,
+ stats);
+ tally.Count(outcome, projectRelativeSourcePath);
+ }
+
+ System.Diagnostics.Stopwatch cleanupWatch = System.Diagnostics.Stopwatch.StartNew();
+ DeleteUnlistedCopies(temporaryDirectory, listedNames);
+ stats.CleanupTicks += cleanupWatch.Elapsed.Ticks;
+ System.Diagnostics.Stopwatch publishWatch = System.Diagnostics.Stopwatch.StartNew();
+ HotReloadSourceStampManifest.Write(temporaryDirectory, manifestLines);
+ Directory.Move(temporaryDirectory, assemblySnapshotDirectory);
+ stats.PublishTicks += publishWatch.Elapsed.Ticks;
+ tally.LogAndWarn(assemblyName);
+ }
+
+ // Keeps the previous copy and its line when the source has not been written since the previous
+ // capture recorded it. Returns false when the source must be copied again.
+ private static bool TryReusePreviousCopy(
+ string projectRoot,
+ string normalizedRelativePath,
+ string snapshotFileName,
+ string copyPath,
+ HotReloadSourceStampManifest previousManifest,
+ long suspectWritesFromUtcTicks,
+ List manifestLines,
+ HotReloadSnapshotCaptureStats stats)
+ {
+ // Why an unmarked line with the same stat lets the copy stand: the previous capture wrote
+ // that line only when the source's stat held while it read it and the bytes were the ones
+ // the previous compile read, or matched the previous PDB. A source with the same length and
+ // write time has not been written since, so this compile, which started after that capture,
+ // read the same bytes.
+ if (!previousManifest.TryGetStamp(snapshotFileName, out long length, out long lastWriteTimeUtcTicks))
+ {
+ return false;
+ }
+
+ if (previousManifest.IsEditedAfterCompile(snapshotFileName))
+ {
+ return false;
+ }
+
+ System.Diagnostics.Stopwatch statWatch = System.Diagnostics.Stopwatch.StartNew();
+ string absoluteSourcePath = Path.Combine(projectRoot, normalizedRelativePath.Replace('/', Path.DirectorySeparatorChar));
+ FileInfo source = new FileInfo(HotReloadFileSystemPath.GetFileSystemPath(absoluteSourcePath));
+ bool unchanged = source.Exists
+ && source.Length == length
+ && source.LastWriteTimeUtc.Ticks == lastWriteTimeUtcTicks;
+ bool copyExists = unchanged && File.Exists(copyPath);
+ stats.StatTicks += statWatch.Elapsed.Ticks;
+ if (!unchanged)
+ {
+ return false;
+ }
+
+ // A defense in depth: a source written inside this compile's suspect window is checked
+ // against this build's PDB instead of being trusted.
+ if (lastWriteTimeUtcTicks >= suspectWritesFromUtcTicks)
+ {
+ return false;
+ }
+
+ if (!copyExists)
+ {
+ return false;
+ }
+
+ manifestLines.Add(HotReloadSourceStampManifest.FormatLine(snapshotFileName, length, lastWriteTimeUtcTicks, false));
+ return true;
+ }
+
+ private static bool TryAdoptPreviousSnapshot(string previousSnapshotDirectory, string temporaryDirectory)
+ {
+ try
+ {
+ Directory.Move(previousSnapshotDirectory, temporaryDirectory);
+ return true;
+ }
+ catch (Exception ex) when (ex is IOException || ex is UnauthorizedAccessException)
+ {
+ // Approved deviation from the no-try-catch rule: on Windows another process holding a
+ // previous copy open stops the move. The previous directory then still sits whole under
+ // its own name, so the capture copies every source instead.
+ return false;
+ }
+ }
+
+ // Deletes the copies of sources the assembly no longer lists, so no reader finds a copy of a
+ // source this build did not compile.
+ private static void DeleteUnlistedCopies(string temporaryDirectory, HashSet listedNames)
+ {
+ foreach (string path in Directory.GetFiles(temporaryDirectory))
+ {
+ string fileName = Path.GetFileName(path);
+ // The manifest is written again right after this.
+ if (string.Equals(fileName, HotReloadConstants.SourceStampManifestFileName, StringComparison.Ordinal))
+ {
+ continue;
+ }
+
+ if (listedNames.Contains(fileName))
+ {
+ continue;
+ }
+
+ File.Delete(path);
+ }
+ }
+ }
+}
diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceSnapshotCopier.Incremental.cs.meta b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceSnapshotCopier.Incremental.cs.meta
new file mode 100644
index 000000000..25245131e
--- /dev/null
+++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceSnapshotCopier.Incremental.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: d6af6c2e0a4fd4776b2159ff33b769b1
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceSnapshotCopier.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceSnapshotCopier.cs
index 7b5c140d2..40886d6be 100644
--- a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceSnapshotCopier.cs
+++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceSnapshotCopier.cs
@@ -13,7 +13,7 @@ namespace io.github.hatayama.UnityCliLoop.FirstPartyTools
/// into a temporary directory first and publishing it with one Move, and decides what the
/// manifest records for each copy.
///
- internal static class HotReloadSourceSnapshotCopier
+ internal static partial class HotReloadSourceSnapshotCopier
{
internal static void CaptureAtomically(
string projectRoot,
@@ -38,11 +38,7 @@ internal static void CaptureAtomically(
Directory.CreateDirectory(temporaryDirectory);
stats.PublishTicks += prepareWatch.Elapsed.Ticks;
- int skippedSourceCount = 0;
- string firstSkippedSourcePath = null;
- int suspectSourceCount = 0;
- int editedAfterCompileCount = 0;
- long checkTicks = 0;
+ SourceCopyTally tally = new SourceCopyTally();
List manifestLines = new List(sourceFiles.Length);
foreach (string projectRelativeSourcePath in sourceFiles)
{
@@ -53,49 +49,14 @@ internal static void CaptureAtomically(
check,
manifestLines,
stats);
- if (outcome.Kind == SourceCopyKind.Unreadable)
- {
- skippedSourceCount++;
- firstSkippedSourcePath ??= projectRelativeSourcePath;
- continue;
- }
-
- if (outcome.Kind == SourceCopyKind.Checked || outcome.Kind == SourceCopyKind.CheckedEditedAfterCompile)
- {
- suspectSourceCount++;
- checkTicks += outcome.CheckTicks;
- }
-
- if (outcome.Kind == SourceCopyKind.CheckedEditedAfterCompile)
- {
- editedAfterCompileCount++;
- }
+ tally.Count(outcome, projectRelativeSourcePath);
}
System.Diagnostics.Stopwatch publishWatch = System.Diagnostics.Stopwatch.StartNew();
HotReloadSourceStampManifest.Write(temporaryDirectory, manifestLines);
Directory.Move(temporaryDirectory, assemblySnapshotDirectory);
stats.PublishTicks += publishWatch.Elapsed.Ticks;
- if (suspectSourceCount > 0)
- {
- VibeLogger.LogInfo(
- HotReloadConstants.VibeLogSourceSnapshotChecked,
- "Hot reload checked the sources written since the compile started against the PDB.",
- new
- {
- assemblyName,
- suspect = suspectSourceCount,
- editedAfterCompile = editedAfterCompileCount,
- checkMs = (long)TimeSpan.FromTicks(checkTicks).TotalMilliseconds
- });
- }
-
- if (skippedSourceCount > 0)
- {
- UnityEngine.Debug.LogWarning(
- $"[UnityCliLoop] Skipped {skippedSourceCount} unreadable source(s) while snapshotting " +
- $"{assemblyName}: {firstSkippedSourcePath}");
- }
+ tally.LogAndWarn(assemblyName);
}
private static SourceCopyOutcome CopySourceFileByteExact(
@@ -278,5 +239,61 @@ internal SourceCopyOutcome(SourceCopyKind kind, long checkTicks)
CheckTicks = checkTicks;
}
}
+
+ // Counts the outcomes of one assembly's copies, then logs the PDB checks and warns about the
+ // sources that could not be read.
+ private sealed class SourceCopyTally
+ {
+ private int _skippedSourceCount;
+ private string _firstSkippedSourcePath;
+ private int _suspectSourceCount;
+ private int _editedAfterCompileCount;
+ private long _checkTicks;
+
+ internal void Count(SourceCopyOutcome outcome, string projectRelativeSourcePath)
+ {
+ if (outcome.Kind == SourceCopyKind.Unreadable)
+ {
+ _skippedSourceCount++;
+ _firstSkippedSourcePath ??= projectRelativeSourcePath;
+ return;
+ }
+
+ if (outcome.Kind == SourceCopyKind.Checked || outcome.Kind == SourceCopyKind.CheckedEditedAfterCompile)
+ {
+ _suspectSourceCount++;
+ _checkTicks += outcome.CheckTicks;
+ }
+
+ if (outcome.Kind == SourceCopyKind.CheckedEditedAfterCompile)
+ {
+ _editedAfterCompileCount++;
+ }
+ }
+
+ internal void LogAndWarn(string assemblyName)
+ {
+ if (_suspectSourceCount > 0)
+ {
+ VibeLogger.LogInfo(
+ HotReloadConstants.VibeLogSourceSnapshotChecked,
+ "Hot reload checked the sources written since the compile started against the PDB.",
+ new
+ {
+ assemblyName,
+ suspect = _suspectSourceCount,
+ editedAfterCompile = _editedAfterCompileCount,
+ checkMs = (long)TimeSpan.FromTicks(_checkTicks).TotalMilliseconds
+ });
+ }
+
+ if (_skippedSourceCount > 0)
+ {
+ UnityEngine.Debug.LogWarning(
+ $"[UnityCliLoop] Skipped {_skippedSourceCount} unreadable source(s) while snapshotting " +
+ $"{assemblyName}: {_firstSkippedSourcePath}");
+ }
+ }
+ }
}
}
diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceSnapshotter.Stamp.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceSnapshotter.Stamp.cs
index 9d1455132..41f96e320 100644
--- a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceSnapshotter.Stamp.cs
+++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceSnapshotter.Stamp.cs
@@ -1,3 +1,4 @@
+using System;
using System.IO;
namespace io.github.hatayama.UnityCliLoop.FirstPartyTools
@@ -41,6 +42,54 @@ private static bool HasMatchingStamp(string stampPath, long dllMtimeTicks, long
return stampedMtimeTicks == dllMtimeTicks && stampedByteLength == dllByteLength;
}
+ // The complete snapshot directory of the build the stamp names, when it has a manifest to reuse;
+ // null when there is none, so the capture copies every source.
+ private static string FindReusablePreviousSnapshotDirectory(
+ string projectRoot,
+ string stampPath,
+ string assemblyName,
+ string currentSnapshotDirectory)
+ {
+ if (!File.Exists(stampPath))
+ {
+ return null;
+ }
+
+ string[] parts = File.ReadAllText(stampPath).Trim().Split(',');
+ if (parts.Length != 3)
+ {
+ return null;
+ }
+
+ // Why the same check as the stale-directory cleanup: only an MVID names a snapshot directory.
+ if (!Guid.TryParseExact(parts[0], "N", out Guid _))
+ {
+ return null;
+ }
+
+ string previousSnapshotDirectory = HotReloadSourceSnapshotLayout.AssemblyDirectory(projectRoot, assemblyName, parts[0]);
+ if (string.Equals(
+ Path.GetFullPath(previousSnapshotDirectory),
+ Path.GetFullPath(currentSnapshotDirectory),
+ StringComparison.OrdinalIgnoreCase))
+ {
+ return null;
+ }
+
+ if (!Directory.Exists(previousSnapshotDirectory))
+ {
+ return null;
+ }
+
+ // A missing, malformed or older-format manifest answers no line, so nothing could be reused.
+ if (HotReloadSourceStampManifest.Load(previousSnapshotDirectory).Count == 0)
+ {
+ return null;
+ }
+
+ return previousSnapshotDirectory;
+ }
+
private static void WriteStamp(string stampPath, string mvid, long dllMtimeTicks, long dllByteLength)
{
File.WriteAllText(stampPath, mvid + "," + dllMtimeTicks + "," + dllByteLength);
diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceSnapshotter.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceSnapshotter.cs
index a1f874e7e..54d3b0e8d 100644
--- a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceSnapshotter.cs
+++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceSnapshotter.cs
@@ -158,13 +158,35 @@ private static void CaptureAssemblyIfNeeded(
pdbPath,
mvid,
documentIndex);
- HotReloadSourceSnapshotCopier.CaptureAtomically(
+ System.Diagnostics.Stopwatch previousWatch = System.Diagnostics.Stopwatch.StartNew();
+ string previousSnapshotDirectory = FindReusablePreviousSnapshotDirectory(
projectRoot,
- assemblySnapshotDirectory,
- sourceFiles,
+ stampPath,
assembly.name,
- check,
- stats);
+ assemblySnapshotDirectory);
+ stats.StampTicks += previousWatch.Elapsed.Ticks;
+ if (previousSnapshotDirectory == null)
+ {
+ HotReloadSourceSnapshotCopier.CaptureAtomically(
+ projectRoot,
+ assemblySnapshotDirectory,
+ sourceFiles,
+ assembly.name,
+ check,
+ stats);
+ }
+ else
+ {
+ HotReloadSourceSnapshotCopier.CaptureIncrementally(
+ projectRoot,
+ assemblySnapshotDirectory,
+ previousSnapshotDirectory,
+ sourceFiles,
+ assembly.name,
+ check,
+ stats);
+ }
+
stats.AssembliesCaptured++;
System.Diagnostics.Stopwatch cleanupWatch = System.Diagnostics.Stopwatch.StartNew();
DeleteStaleSnapshotDirectories(snapshotRoot, assembly.name, assemblySnapshotDirectory);
diff --git a/docs/hot-reload.md b/docs/hot-reload.md
index 26352e61c..36606e3e3 100644
--- a/docs/hot-reload.md
+++ b/docs/hot-reload.md
@@ -78,7 +78,9 @@ records each copied source's length and write time, so a run after a domain relo
unchanged sibling by a stat instead of reading it; a source written since the compile started
whose bytes do not match the PDB checksum carries a mark that it was edited after the compile,
and the default selection, the sibling scan and the skip check treat a marked source as
-changed),
+changed; a capture moves the previous build's directory to the new MVID's and rewrites only the
+sources whose length or write time changed, that carry no unmarked manifest line, or that were
+written since the compile started; it deletes the copies of sources no longer in the assembly),
`Library/UloopHotReload/PdbDocuments/fmt1/.txt` (the documents the PDB's sequence
points refer to, stamped with the dll's and the PDB's length and write time and the MVID, so the
first run after a domain reload does not walk the PDB again while they still match), and
diff --git a/docs/vibe-logs.md b/docs/vibe-logs.md
index 9a799638e..7aa174458 100644
--- a/docs/vibe-logs.md
+++ b/docs/vibe-logs.md
@@ -100,19 +100,24 @@ command ran against:
capture threw. It says where the capture spent its time:
- `getAssembliesMs`: listing the compilation assemblies from Unity.
- `packageLookupMs`: asking Package Manager whether each assembly's sources are immutable.
- - `stampMs`: reading and writing the stamps beside the snapshots.
+ - `stampMs`: reading and writing the stamps beside the snapshots, and reading the previous
+ build's manifest.
- `mvidMs`: reading the MVID of each compiled dll whose stamp did not match.
- - `statMs`: reading each source's length and write time before and after it is copied.
- - `readWriteMs`: reading each source and writing its copy.
+ - `statMs`: reading each source's length and write time before and after it is copied, and
+ checking whether a source and its previous copy are unchanged.
+ - `readWriteMs`: reading each source and writing its copy, including deleting the previous copy
+ it replaces.
- `pdbCheckMs`: checking the sources written since the compile started against the PDB.
- - `publishMs`: deleting a leftover temporary directory and creating a new one, writing the
- manifest, and moving the directory into place.
- - `cleanupMs`: deleting the snapshot directories of earlier builds.
+ - `publishMs`: deleting a leftover temporary directory and creating a new one or moving the
+ previous build's directory there, writing the manifest, and moving the directory into place.
+ - `cleanupMs`: deleting the snapshot directories of earlier builds, and the copies of sources
+ the assembly no longer lists.
- `totalMs`: the whole capture.
- `assemblies`: the compilation assemblies listed.
- `assembliesImmutable`: those skipped because their sources belong to an immutable package.
- `assembliesUnchanged`: those skipped because their stamp matched the compiled dll.
- `assembliesCaptured`: those whose sources were copied into a new snapshot directory.
+ - `filesReused`: the sources whose previous copy was kept without reading them.
- `filesCopied` and `bytesCopied`: the sources copied and their total size.
- `filesChecked`: the sources checked against the PDB.
The phases add up to `totalMs` except for the loop overhead; `captureMs` in the captured entry