diff --git a/.agents/skills/uloop-hot-reload/references/scope-and-limits.md b/.agents/skills/uloop-hot-reload/references/scope-and-limits.md
index 25e9f5d6b..38d56ec0b 100644
--- a/.agents/skills/uloop-hot-reload/references/scope-and-limits.md
+++ b/.agents/skills/uloop-hot-reload/references/scope-and-limits.md
@@ -348,6 +348,12 @@ line each. A file with a declaration that hot reload refused to introduce (its
`Warnings` line says the type requires a compile) gets no such line, because that
compile also establishes its baseline. This also holds for an existing file that
gains such a declaration, for example a nested type or a delegate.
+Within one Editor session, a source compared with its baseline once is compared again
+only when its length or last write time changes; a rewrite that keeps both (for example
+a copy that preserves timestamps) is noticed only after the next domain reload, such as
+`uloop compile`. This applies wherever a source is compared with its baseline: finding
+drifted siblings, deciding which live patches to re-apply after a skip, and selecting
+files when `--files` is omitted.
Property getters with a body (including expression-bodied properties) are patched
like ordinary methods. Editing a compiled property's setter, init, or indexer accessor
diff --git a/.claude/skills/uloop-hot-reload/references/scope-and-limits.md b/.claude/skills/uloop-hot-reload/references/scope-and-limits.md
index 25e9f5d6b..38d56ec0b 100644
--- a/.claude/skills/uloop-hot-reload/references/scope-and-limits.md
+++ b/.claude/skills/uloop-hot-reload/references/scope-and-limits.md
@@ -348,6 +348,12 @@ line each. A file with a declaration that hot reload refused to introduce (its
`Warnings` line says the type requires a compile) gets no such line, because that
compile also establishes its baseline. This also holds for an existing file that
gains such a declaration, for example a nested type or a delegate.
+Within one Editor session, a source compared with its baseline once is compared again
+only when its length or last write time changes; a rewrite that keeps both (for example
+a copy that preserves timestamps) is noticed only after the next domain reload, such as
+`uloop compile`. This applies wherever a source is compared with its baseline: finding
+drifted siblings, deciding which live patches to re-apply after a skip, and selecting
+files when `--files` is omitted.
Property getters with a body (including expression-bodied properties) are patched
like ordinary methods. Editing a compiled property's setter, init, or indexer accessor
diff --git a/Assets/Tests/Editor/HotReload/HotReloadChangedSiblingSourceDetectorTests.cs b/Assets/Tests/Editor/HotReload/HotReloadChangedSiblingSourceDetectorTests.cs
index b475459c7..d26838329 100644
--- a/Assets/Tests/Editor/HotReload/HotReloadChangedSiblingSourceDetectorTests.cs
+++ b/Assets/Tests/Editor/HotReload/HotReloadChangedSiblingSourceDetectorTests.cs
@@ -17,6 +17,11 @@ namespace io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload
///
public class HotReloadChangedSiblingSourceDetectorTests
{
+ // Why fixed write times: the detector reuses a verdict while a file's length and write time
+ // stay the same, so each test sets the stamp it means instead of relying on the clock.
+ private static readonly DateTime FirstWriteTimeUtc = new DateTime(2020, 1, 1, 0, 0, 0, DateTimeKind.Utc);
+ private static readonly DateTime SecondWriteTimeUtc = FirstWriteTimeUtc.AddDays(1);
+
///
/// What: a sibling whose on-disk bytes differ from its snapshot is returned, and the
/// edited file itself is excluded even when it also differs.
@@ -504,6 +509,142 @@ public void SourceMatchesSnapshot_NoPdb_IsFalse()
}
}
+ ///
+ /// What: a sibling that matched its snapshot is reported once it is rewritten with another
+ /// length while its write time stays the same, so the length alone triggers a new comparison.
+ ///
+ [Test]
+ public void DetectFromSnapshotDirectory_SecondScanAfterRewriteWithNewLength_ReturnsSibling()
+ {
+ string projectRoot = CreateTempProjectRoot();
+ try
+ {
+ string siblingRelative = "Assets/Sibling.cs";
+ WriteSnapshot(projectRoot, "Asm-mvid", siblingRelative, "sibling-AAAA");
+ WriteProjectFileAt(projectRoot, siblingRelative, "sibling-AAAA", FirstWriteTimeUtc);
+ Assert.That(ScanSibling(projectRoot, "Asm-mvid", siblingRelative), Is.Empty);
+
+ WriteProjectFileAt(projectRoot, siblingRelative, "sibling-AAAA-longer", FirstWriteTimeUtc);
+
+ Assert.That(
+ ScanSibling(projectRoot, "Asm-mvid", siblingRelative),
+ Is.EqualTo(new[] { AbsoluteProjectPath(projectRoot, siblingRelative) }));
+ }
+ finally
+ {
+ Directory.Delete(projectRoot, recursive: true);
+ }
+ }
+
+ ///
+ /// What: a sibling that matched its snapshot is reported once it is rewritten with the same
+ /// length and a later write time, so the write time alone triggers a new comparison.
+ ///
+ [Test]
+ public void DetectFromSnapshotDirectory_SecondScanAfterRewriteWithNewWriteTime_ReturnsSibling()
+ {
+ string projectRoot = CreateTempProjectRoot();
+ try
+ {
+ string siblingRelative = "Assets/Sibling.cs";
+ WriteSnapshot(projectRoot, "Asm-mvid", siblingRelative, "sibling-AAAA");
+ WriteProjectFileAt(projectRoot, siblingRelative, "sibling-AAAA", FirstWriteTimeUtc);
+ Assert.That(ScanSibling(projectRoot, "Asm-mvid", siblingRelative), Is.Empty);
+
+ WriteProjectFileAt(projectRoot, siblingRelative, "sibling-BBBB", SecondWriteTimeUtc);
+
+ Assert.That(
+ ScanSibling(projectRoot, "Asm-mvid", siblingRelative),
+ Is.EqualTo(new[] { AbsoluteProjectPath(projectRoot, siblingRelative) }));
+ }
+ finally
+ {
+ Directory.Delete(projectRoot, recursive: true);
+ }
+ }
+
+ ///
+ /// What: a sibling rewritten with the same length while its write time is put back keeps
+ /// the earlier "matches" verdict. This is a known limit: a change that keeps both length and
+ /// write time (a copy that preserves timestamps) is not noticed until the next compile.
+ ///
+ [Test]
+ public void DetectFromSnapshotDirectory_SecondScanWithSameStamp_KeepsTheMatchVerdict()
+ {
+ string projectRoot = CreateTempProjectRoot();
+ try
+ {
+ string siblingRelative = "Assets/Sibling.cs";
+ WriteSnapshot(projectRoot, "Asm-mvid", siblingRelative, "sibling-AAAA");
+ WriteProjectFileAt(projectRoot, siblingRelative, "sibling-AAAA", FirstWriteTimeUtc);
+ Assert.That(ScanSibling(projectRoot, "Asm-mvid", siblingRelative), Is.Empty);
+
+ WriteProjectFileAt(projectRoot, siblingRelative, "sibling-BBBB", FirstWriteTimeUtc);
+
+ Assert.That(ScanSibling(projectRoot, "Asm-mvid", siblingRelative), Is.Empty);
+ }
+ finally
+ {
+ Directory.Delete(projectRoot, recursive: true);
+ }
+ }
+
+ ///
+ /// What: a verdict against one assembly generation's snapshot is not reused against
+ /// another generation's snapshot of the same file.
+ ///
+ [Test]
+ public void DetectFromSnapshotDirectory_SameSiblingAgainstAnotherSnapshotDirectory_ComparesAgain()
+ {
+ string projectRoot = CreateTempProjectRoot();
+ try
+ {
+ string siblingRelative = "Assets/Sibling.cs";
+ WriteSnapshot(projectRoot, "Asm-mvid1", siblingRelative, "sibling-AAAA");
+ WriteSnapshot(projectRoot, "Asm-mvid2", siblingRelative, "sibling-BBBB");
+ WriteProjectFileAt(projectRoot, siblingRelative, "sibling-AAAA", FirstWriteTimeUtc);
+ Assert.That(ScanSibling(projectRoot, "Asm-mvid1", siblingRelative), Is.Empty);
+
+ Assert.That(
+ ScanSibling(projectRoot, "Asm-mvid2", siblingRelative),
+ Is.EqualTo(new[] { AbsoluteProjectPath(projectRoot, siblingRelative) }));
+ }
+ finally
+ {
+ Directory.Delete(projectRoot, recursive: true);
+ }
+ }
+
+ ///
+ /// What: a sibling put back to its snapshot bytes while its length and write time are
+ /// kept keeps the earlier "differs" verdict. This is the same known limit in the other
+ /// direction: it stays reported until the next compile.
+ ///
+ [Test]
+ public void DetectFromSnapshotDirectory_SecondScanWithSameStamp_KeepsTheDiffersVerdict()
+ {
+ string projectRoot = CreateTempProjectRoot();
+ try
+ {
+ string siblingRelative = "Assets/Sibling.cs";
+ WriteSnapshot(projectRoot, "Asm-mvid", siblingRelative, "sibling-AAAA");
+ WriteProjectFileAt(projectRoot, siblingRelative, "sibling-BBBB", FirstWriteTimeUtc);
+ Assert.That(
+ ScanSibling(projectRoot, "Asm-mvid", siblingRelative),
+ Is.EqualTo(new[] { AbsoluteProjectPath(projectRoot, siblingRelative) }));
+
+ WriteProjectFileAt(projectRoot, siblingRelative, "sibling-AAAA", FirstWriteTimeUtc);
+
+ Assert.That(
+ ScanSibling(projectRoot, "Asm-mvid", siblingRelative),
+ Is.EqualTo(new[] { AbsoluteProjectPath(projectRoot, siblingRelative) }));
+ }
+ finally
+ {
+ Directory.Delete(projectRoot, recursive: true);
+ }
+ }
+
///
/// What: sibling-derived warnings are ordinal-deduped among themselves and skipped
/// when the own-file list already contains the exact string, without collapsing
@@ -524,6 +665,38 @@ public void AppendSiblingDerivedWarnings_DedupesAmongSiblingsAndSkipsOwnFileMatc
Is.EqualTo(new[] { "own-a", "own-a", "shared", "sibling-b" }));
}
+ // Why an edited file: a run always has one, and the detector requires it.
+ private const string EditedRelative = "Assets/Edited.cs";
+
+ private static string[] ScanSibling(
+ string projectRoot,
+ string assemblySnapshotDirectoryName,
+ string siblingRelative)
+ {
+ return HotReloadChangedSiblingSourceDetector.DetectFromSnapshotDirectory(
+ projectRoot,
+ assemblySnapshotDirectoryName,
+ new[] { EditedRelative, siblingRelative },
+ new[] { EditedRelative }).ChangedSiblingAbsolutePaths;
+ }
+
+ // Why a write time the test chooses: SetLastWriteTimeUtc stores microseconds while
+ // LastWriteTimeUtc reports 100 ns ticks, so only a chosen value can be put back exactly.
+ private static void WriteProjectFileAt(
+ string projectRoot,
+ string projectRelativePath,
+ string contents,
+ DateTime lastWriteTimeUtc)
+ {
+ WriteProjectFile(projectRoot, projectRelativePath, contents);
+ string absolutePath = AbsoluteProjectPath(projectRoot, projectRelativePath);
+ File.SetLastWriteTimeUtc(absolutePath, lastWriteTimeUtc);
+ Assert.That(
+ new FileInfo(absolutePath).LastWriteTimeUtc,
+ Is.EqualTo(lastWriteTimeUtc),
+ "The write time must be settable exactly, or the stamp the detector reads is not the one the test chose.");
+ }
+
private static string CreateTempProjectRoot()
{
string projectRoot = Path.Combine(
diff --git a/Assets/Tests/Editor/HotReload/HotReloadChangedSourceDetectorTests.cs b/Assets/Tests/Editor/HotReload/HotReloadChangedSourceDetectorTests.cs
index 5fdab2ea2..f80afa95f 100644
--- a/Assets/Tests/Editor/HotReload/HotReloadChangedSourceDetectorTests.cs
+++ b/Assets/Tests/Editor/HotReload/HotReloadChangedSourceDetectorTests.cs
@@ -14,6 +14,11 @@ namespace io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload
///
public class HotReloadChangedSourceDetectorTests
{
+ // Why fixed write times: the detector reuses a verdict while a file's length and write time
+ // stay the same, so each test sets the stamp it means instead of relying on the clock.
+ private static readonly DateTime FirstWriteTimeUtc = new DateTime(2020, 1, 1, 0, 0, 0, DateTimeKind.Utc);
+ private static readonly DateTime SecondWriteTimeUtc = FirstWriteTimeUtc.AddDays(1);
+
///
/// What: a missing snapshot directory reports no baseline instead of conflating it with no changes.
///
@@ -178,6 +183,82 @@ public void DetectAllChangedFromSnapshotDirectory_WhenMoreThanLimitChanged_Trunc
}
}
+ ///
+ /// What: the default file selection does not pick a source rewritten while it keeps its
+ /// length and write time, because the earlier "matches" verdict is reused. This is a known
+ /// limit until the next compile.
+ ///
+ [Test]
+ public void DetectAllChangedFromSnapshotDirectory_SecondScanWithSameStamp_DoesNotSelectTheRewrite()
+ {
+ string projectRoot = CreateTempProjectRoot();
+ try
+ {
+ string sourcePath = "Assets/Source.cs";
+ WriteSnapshot(projectRoot, "Assembly-mvid", sourcePath, "source-AAAA");
+ WriteProjectFileAt(projectRoot, sourcePath, "source-AAAA", FirstWriteTimeUtc);
+ Assert.That(ScanAll(projectRoot, sourcePath), Is.Empty);
+
+ WriteProjectFileAt(projectRoot, sourcePath, "source-BBBB", FirstWriteTimeUtc);
+
+ Assert.That(ScanAll(projectRoot, sourcePath), Is.Empty);
+ }
+ finally
+ {
+ Directory.Delete(projectRoot, recursive: true);
+ }
+ }
+
+ ///
+ /// What: the default file selection picks a source rewritten with a later write time after
+ /// an earlier scan found it unchanged.
+ ///
+ [Test]
+ public void DetectAllChangedFromSnapshotDirectory_SecondScanAfterRewriteWithNewWriteTime_SelectsTheRewrite()
+ {
+ string projectRoot = CreateTempProjectRoot();
+ try
+ {
+ string sourcePath = "Assets/Source.cs";
+ WriteSnapshot(projectRoot, "Assembly-mvid", sourcePath, "source-AAAA");
+ WriteProjectFileAt(projectRoot, sourcePath, "source-AAAA", FirstWriteTimeUtc);
+ Assert.That(ScanAll(projectRoot, sourcePath), Is.Empty);
+
+ WriteProjectFileAt(projectRoot, sourcePath, "source-BBBB", SecondWriteTimeUtc);
+
+ Assert.That(ScanAll(projectRoot, sourcePath), Is.EqualTo(new[] { sourcePath }));
+ }
+ finally
+ {
+ Directory.Delete(projectRoot, recursive: true);
+ }
+ }
+
+ private static System.Collections.Generic.List ScanAll(string projectRoot, string sourcePath)
+ {
+ return HotReloadChangedSiblingSourceDetector.DetectAllChangedFromSnapshotDirectory(
+ projectRoot,
+ "Assembly-mvid",
+ new[] { sourcePath }).ChangedProjectRelativePaths;
+ }
+
+ // Why a write time the test chooses: SetLastWriteTimeUtc stores microseconds while
+ // LastWriteTimeUtc reports 100 ns ticks, so only a chosen value can be put back exactly.
+ private static void WriteProjectFileAt(
+ string projectRoot,
+ string projectRelativePath,
+ string contents,
+ DateTime lastWriteTimeUtc)
+ {
+ WriteProjectFile(projectRoot, projectRelativePath, contents);
+ string absolutePath = AbsoluteProjectPath(projectRoot, projectRelativePath);
+ File.SetLastWriteTimeUtc(absolutePath, lastWriteTimeUtc);
+ Assert.That(
+ new FileInfo(absolutePath).LastWriteTimeUtc,
+ Is.EqualTo(lastWriteTimeUtc),
+ "The write time must be settable exactly, or the stamp the detector reads is not the one the test chose.");
+ }
+
private static string CreateTempProjectRoot()
{
string projectRoot = Path.Combine(
diff --git a/Assets/Tests/Editor/HotReload/HotReloadSiblingVerdictCacheTests.cs b/Assets/Tests/Editor/HotReload/HotReloadSiblingVerdictCacheTests.cs
new file mode 100644
index 000000000..512195bef
--- /dev/null
+++ b/Assets/Tests/Editor/HotReload/HotReloadSiblingVerdictCacheTests.cs
@@ -0,0 +1,137 @@
+using NUnit.Framework;
+
+using io.github.hatayama.UnityCliLoop.FirstPartyTools;
+
+namespace io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload
+{
+ ///
+ /// Verifies when the sibling verdict cache answers from a recorded verdict and when it does not.
+ ///
+ public sealed class HotReloadSiblingVerdictCacheTests
+ {
+ private const string SnapshotPath = "/proj/Library/UloopHotReload/SourceSnapshot/Asm-mvid1/a.cs";
+ private const string OtherSnapshotPath = "/proj/Library/UloopHotReload/SourceSnapshot/Asm-mvid2/a.cs";
+ private const string SourcePath = "/proj/Assets/A.cs";
+ private const string OtherSourcePath = "/proj/Temp/Copy/A.cs";
+
+ ///
+ /// What: an empty cache has no verdict.
+ ///
+ [Test]
+ public void TryGetVerdict_WhenNothingRecorded_ReturnsFalse()
+ {
+ HotReloadSiblingVerdictCache cache = new HotReloadSiblingVerdictCache();
+
+ bool found = cache.TryGetVerdict(SnapshotPath, SourcePath, 10, 100, out bool _);
+
+ Assert.That(found, Is.False);
+ }
+
+ ///
+ /// What: a recorded verdict is returned for the same pair and the same stamp.
+ ///
+ [Test]
+ public void TryGetVerdict_SamePairAndStamp_ReturnsRecordedVerdict()
+ {
+ HotReloadSiblingVerdictCache cache = new HotReloadSiblingVerdictCache();
+ cache.Record(SnapshotPath, SourcePath, 10, 100, true);
+
+ bool found = cache.TryGetVerdict(SnapshotPath, SourcePath, 10, 100, out bool matches);
+
+ Assert.That(found, Is.True);
+ Assert.That(matches, Is.True);
+ }
+
+ ///
+ /// What: a different length makes the recorded verdict unusable.
+ ///
+ [Test]
+ public void TryGetVerdict_DifferentLength_ReturnsFalse()
+ {
+ HotReloadSiblingVerdictCache cache = new HotReloadSiblingVerdictCache();
+ cache.Record(SnapshotPath, SourcePath, 10, 100, true);
+
+ bool found = cache.TryGetVerdict(SnapshotPath, SourcePath, 11, 100, out bool _);
+
+ Assert.That(found, Is.False);
+ }
+
+ ///
+ /// What: a different last write time makes the recorded verdict unusable.
+ ///
+ [Test]
+ public void TryGetVerdict_DifferentWriteTime_ReturnsFalse()
+ {
+ HotReloadSiblingVerdictCache cache = new HotReloadSiblingVerdictCache();
+ cache.Record(SnapshotPath, SourcePath, 10, 100, true);
+
+ bool found = cache.TryGetVerdict(SnapshotPath, SourcePath, 10, 101, out bool _);
+
+ Assert.That(found, Is.False);
+ }
+
+ ///
+ /// What: the same source compared with another snapshot has no verdict, because the
+ /// snapshot path is part of the key.
+ ///
+ [Test]
+ public void TryGetVerdict_OtherSnapshotPath_ReturnsFalse()
+ {
+ HotReloadSiblingVerdictCache cache = new HotReloadSiblingVerdictCache();
+ cache.Record(SnapshotPath, SourcePath, 10, 100, true);
+
+ bool found = cache.TryGetVerdict(OtherSnapshotPath, SourcePath, 10, 100, out bool _);
+
+ Assert.That(found, Is.False);
+ }
+
+ ///
+ /// What: another source compared with the same snapshot has no verdict, because the
+ /// source path is part of the key.
+ ///
+ [Test]
+ public void TryGetVerdict_OtherSourcePath_ReturnsFalse()
+ {
+ HotReloadSiblingVerdictCache cache = new HotReloadSiblingVerdictCache();
+ cache.Record(SnapshotPath, SourcePath, 10, 100, true);
+
+ bool found = cache.TryGetVerdict(SnapshotPath, OtherSourcePath, 10, 100, out bool _);
+
+ Assert.That(found, Is.False);
+ }
+
+ ///
+ /// What: recording the pair again replaces the earlier verdict.
+ ///
+ [Test]
+ public void Record_SamePairAgain_ReplacesTheVerdict()
+ {
+ HotReloadSiblingVerdictCache cache = new HotReloadSiblingVerdictCache();
+ cache.Record(SnapshotPath, SourcePath, 10, 100, true);
+ cache.Record(SnapshotPath, SourcePath, 10, 100, false);
+
+ bool found = cache.TryGetVerdict(SnapshotPath, SourcePath, 10, 100, out bool matches);
+
+ Assert.That(found, Is.True);
+ Assert.That(matches, Is.False);
+ Assert.That(cache.Count, Is.EqualTo(1));
+ }
+
+ ///
+ /// What: Clear drops every recorded verdict.
+ ///
+ [Test]
+ public void Clear_DropsEveryVerdict()
+ {
+ HotReloadSiblingVerdictCache cache = new HotReloadSiblingVerdictCache();
+ cache.Record(SnapshotPath, SourcePath, 10, 100, true);
+ cache.Record(OtherSnapshotPath, SourcePath, 10, 100, false);
+
+ cache.Clear();
+
+ bool found = cache.TryGetVerdict(SnapshotPath, SourcePath, 10, 100, out bool _);
+ Assert.That(found, Is.False);
+ Assert.That(cache.Count, Is.EqualTo(0));
+ }
+ }
+}
diff --git a/Assets/Tests/Editor/HotReload/HotReloadSiblingVerdictCacheTests.cs.meta b/Assets/Tests/Editor/HotReload/HotReloadSiblingVerdictCacheTests.cs.meta
new file mode 100644
index 000000000..6b2fb1811
--- /dev/null
+++ b/Assets/Tests/Editor/HotReload/HotReloadSiblingVerdictCacheTests.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: e765a53c312f6487fa098ff2e8991402
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadChangedSiblingSourceDetector.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadChangedSiblingSourceDetector.cs
index ae717ee7f..6e008adee 100644
--- a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadChangedSiblingSourceDetector.cs
+++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadChangedSiblingSourceDetector.cs
@@ -3,6 +3,8 @@
using System.Globalization;
using System.IO;
+using UnityEditor.Compilation;
+
using UnityEngine;
namespace io.github.hatayama.UnityCliLoop.FirstPartyTools
@@ -13,6 +15,17 @@ namespace io.github.hatayama.UnityCliLoop.FirstPartyTools
///
internal static class HotReloadChangedSiblingSourceDetector
{
+ // Why per domain: a verdict compares a file with a snapshot that never changes for its MVID, so it can only
+ // go stale when the file itself changes, and a save changes the file's length or write time.
+ private static readonly HotReloadSiblingVerdictCache _verdicts = new HotReloadSiblingVerdictCache();
+
+ static HotReloadChangedSiblingSourceDetector()
+ {
+ // Why: a compile that fails keeps the domain alive, so without this the memo would keep
+ // verdicts for an assembly generation that is no longer current.
+ CompilationPipeline.compilationStarted += _ => _verdicts.Clear();
+ }
+
///
/// Returns changed sibling absolute paths for , excluding
/// . Missing snapshots or DLLs yield an empty
@@ -159,7 +172,13 @@ internal static bool SourceMatchesSnapshotDirectory(
"assemblySnapshotDirectoryName must not be null or empty.");
Debug.Assert(!string.IsNullOrEmpty(projectRelativePath), "projectRelativePath must not be null or empty.");
- if (string.IsNullOrEmpty(sourcePath) || !File.Exists(sourcePath))
+ if (string.IsNullOrEmpty(sourcePath))
+ {
+ return false;
+ }
+
+ FileInfo source = new FileInfo(sourcePath);
+ if (!source.Exists)
{
return false;
}
@@ -174,7 +193,7 @@ internal static bool SourceMatchesSnapshotDirectory(
return false;
}
- return BytesEqual(File.ReadAllBytes(sourcePath), File.ReadAllBytes(snapshotPath));
+ return MatchesSnapshot(source, snapshotPath);
}
private static HotReloadChangedSourceScanResult DetectChangedFromSnapshotDirectory(
@@ -232,7 +251,8 @@ private static string TryResolveChangedProjectRelativePath(
}
string absoluteSourcePath = ToAbsoluteProjectPath(projectRoot, normalizedRelativePath);
- if (!File.Exists(absoluteSourcePath))
+ FileInfo source = new FileInfo(absoluteSourcePath);
+ if (!source.Exists)
{
return null;
}
@@ -245,9 +265,7 @@ private static string TryResolveChangedProjectRelativePath(
return null;
}
- byte[] diskBytes = File.ReadAllBytes(absoluteSourcePath);
- byte[] snapshotBytes = File.ReadAllBytes(snapshotPath);
- if (BytesEqual(diskBytes, snapshotBytes))
+ if (MatchesSnapshot(source, snapshotPath))
{
return null;
}
@@ -256,6 +274,28 @@ private static string TryResolveChangedProjectRelativePath(
return normalizedRelativePath;
}
+ // Compares an existing source with an existing snapshot file, reusing the last verdict while
+ // the source keeps its length and write time.
+ private static bool MatchesSnapshot(FileInfo source, string snapshotPath)
+ {
+ long lastWriteTimeUtcTicks = source.LastWriteTimeUtc.Ticks;
+ if (_verdicts.TryGetVerdict(
+ snapshotPath,
+ source.FullName,
+ source.Length,
+ lastWriteTimeUtcTicks,
+ out bool matches))
+ {
+ return matches;
+ }
+
+ bool equal = BytesEqual(File.ReadAllBytes(source.FullName), File.ReadAllBytes(snapshotPath));
+ // Why the stamp taken before reading: a write during the read moves the stamp on, so the
+ // next scan reads the file again instead of trusting this verdict.
+ _verdicts.Record(snapshotPath, source.FullName, source.Length, lastWriteTimeUtcTicks, equal);
+ return equal;
+ }
+
private static HotReloadChangedSourceScanResult LimitChangedSources(
List changedProjectRelativePaths)
{
diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadSiblingVerdictCache.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadSiblingVerdictCache.cs
new file mode 100644
index 000000000..cd1cb8164
--- /dev/null
+++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadSiblingVerdictCache.cs
@@ -0,0 +1,111 @@
+using System.Collections.Generic;
+
+using UnityEngine;
+
+namespace io.github.hatayama.UnityCliLoop.FirstPartyTools
+{
+ ///
+ /// Remembers whether a source file matched its snapshot, together with the file's length and
+ /// last write time, so an unchanged file need not be read again.
+ ///
+ internal sealed class HotReloadSiblingVerdictCache
+ {
+ // Why both paths in the key: the same file compared with another assembly generation's
+ // snapshot is a different verdict, and the same snapshot can be compared with another file.
+ private readonly Dictionary<(string snapshotPath, string sourcePath), Entry> _entries =
+ new Dictionary<(string snapshotPath, string sourcePath), Entry>();
+
+ // Why a lock: the rebind planner compares on a thread-pool continuation while
+ // compilationStarted clears the memo on the main thread.
+ private readonly object _gate = new object();
+
+ ///
+ /// Returns true with the remembered verdict when the pair was recorded with the same stamp.
+ ///
+ ///
+ /// Why a changed stamp reads as no verdict: the caller then has a single path that reads,
+ /// compares, and records again, so a stale verdict is never returned.
+ ///
+ internal bool TryGetVerdict(
+ string snapshotPath,
+ string sourcePath,
+ long length,
+ long lastWriteTimeUtcTicks,
+ out bool matchesSnapshot)
+ {
+ Debug.Assert(!string.IsNullOrEmpty(snapshotPath), "snapshotPath must not be null or empty.");
+ Debug.Assert(!string.IsNullOrEmpty(sourcePath), "sourcePath must not be null or empty.");
+
+ Entry entry;
+ lock (_gate)
+ {
+ if (!_entries.TryGetValue((snapshotPath, sourcePath), out entry))
+ {
+ matchesSnapshot = false;
+ return false;
+ }
+ }
+
+ if (entry.Length != length || entry.LastWriteTimeUtcTicks != lastWriteTimeUtcTicks)
+ {
+ matchesSnapshot = false;
+ return false;
+ }
+
+ matchesSnapshot = entry.MatchesSnapshot;
+ return true;
+ }
+
+ ///
+ /// Records the verdict for the pair, replacing any earlier one.
+ ///
+ internal void Record(
+ string snapshotPath,
+ string sourcePath,
+ long length,
+ long lastWriteTimeUtcTicks,
+ bool matchesSnapshot)
+ {
+ Debug.Assert(!string.IsNullOrEmpty(snapshotPath), "snapshotPath must not be null or empty.");
+ Debug.Assert(!string.IsNullOrEmpty(sourcePath), "sourcePath must not be null or empty.");
+
+ lock (_gate)
+ {
+ _entries[(snapshotPath, sourcePath)] = new Entry(length, lastWriteTimeUtcTicks, matchesSnapshot);
+ }
+ }
+
+ internal void Clear()
+ {
+ lock (_gate)
+ {
+ _entries.Clear();
+ }
+ }
+
+ internal int Count
+ {
+ get
+ {
+ lock (_gate)
+ {
+ return _entries.Count;
+ }
+ }
+ }
+
+ private readonly struct Entry
+ {
+ internal readonly long Length;
+ internal readonly long LastWriteTimeUtcTicks;
+ internal readonly bool MatchesSnapshot;
+
+ internal Entry(long length, long lastWriteTimeUtcTicks, bool matchesSnapshot)
+ {
+ Length = length;
+ LastWriteTimeUtcTicks = lastWriteTimeUtcTicks;
+ MatchesSnapshot = matchesSnapshot;
+ }
+ }
+ }
+}
diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadSiblingVerdictCache.cs.meta b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadSiblingVerdictCache.cs.meta
new file mode 100644
index 000000000..5e1455b13
--- /dev/null
+++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadSiblingVerdictCache.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 9fe33db03b44e461f9e50087fcf28ff1
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Skill/references/scope-and-limits.md b/Packages/src/Editor/FirstPartyTools/HotReload/Skill/references/scope-and-limits.md
index 25e9f5d6b..38d56ec0b 100644
--- a/Packages/src/Editor/FirstPartyTools/HotReload/Skill/references/scope-and-limits.md
+++ b/Packages/src/Editor/FirstPartyTools/HotReload/Skill/references/scope-and-limits.md
@@ -348,6 +348,12 @@ line each. A file with a declaration that hot reload refused to introduce (its
`Warnings` line says the type requires a compile) gets no such line, because that
compile also establishes its baseline. This also holds for an existing file that
gains such a declaration, for example a nested type or a delegate.
+Within one Editor session, a source compared with its baseline once is compared again
+only when its length or last write time changes; a rewrite that keeps both (for example
+a copy that preserves timestamps) is noticed only after the next domain reload, such as
+`uloop compile`. This applies wherever a source is compared with its baseline: finding
+drifted siblings, deciding which live patches to re-apply after a skip, and selecting
+files when `--files` is omitted.
Property getters with a body (including expression-bodied properties) are patched
like ordinary methods. Editing a compiled property's setter, init, or indexer accessor