-
Notifications
You must be signed in to change notification settings - Fork 70
[#99] Add --build-history option to analyze #109
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
137933a
f933273
ccfc50a
5ed3eec
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -3,7 +3,6 @@ | |
| using System.Diagnostics; | ||
| using System.IO; | ||
| using System.Linq; | ||
| using Newtonsoft.Json; | ||
| using UnityDataTools.Analyzer.SQLite.Handlers; | ||
| using UnityDataTools.Analyzer.SQLite.Parsers; | ||
| using UnityDataTools.Analyzer.SQLite.Writers; | ||
|
|
@@ -46,6 +45,9 @@ public class AnalyzeOptions | |
| public bool SkipCrc { get; init; } | ||
| public bool Verbose { get; init; } | ||
| public bool NoRecursion { get; init; } | ||
| // Build history folder (e.g. Library/BuildHistory); when set, the folder of the analyzed | ||
| // build is located in it and its ContentLayout.json and build report join the input. | ||
| public string BuildHistoryPath { get; init; } | ||
| } | ||
|
|
||
| public int Analyze(AnalyzeOptions options) | ||
|
|
@@ -183,44 +185,21 @@ public int Analyze(AnalyzeOptions options) | |
| } | ||
|
|
||
| // Validates the ContentDirectory-related inputs and prepares the file list (issue #99): | ||
| // enforces that a single build is analyzed, selects the ContentLayout.json whose | ||
| // enforces that a single build is analyzed, adds the files of the matching build history | ||
| // folder when --build-history is used, selects the ContentLayout.json whose | ||
| // BuildManifestHash matches that build (dropping any others), and moves it to the front of | ||
| // the list so it is imported before the content files whose references it resolves. Returns | ||
| // false, after printing an error, when the input combination is invalid. | ||
| bool PrepareContentDirectoryInputs(List<(string FullPath, string DisplayRoot)> files) | ||
| { | ||
| const string hashFileName = "BuildManifestHash.txt"; | ||
|
|
||
| var layoutCandidates = files.Where(f => ContentLayoutParser.IsContentLayoutFile(f.FullPath)).ToList(); | ||
| var hashFiles = files | ||
| .Where(f => string.Equals(Path.GetFileName(f.FullPath), hashFileName, StringComparison.OrdinalIgnoreCase)) | ||
| .Select(f => f.FullPath) | ||
| .ToList(); | ||
|
|
||
| // ContentDirectory output is recognized by its .cf content files, or by its archive when | ||
| // built compressed. The BuildManifestHash.txt identifying the build sits next to them; it | ||
| // is not always on the input (e.g. when specific files are passed), so pick it up from | ||
| // the directories containing the content. | ||
| var contentDirectories = files | ||
| .Where(f => HasExtension(f.FullPath, ".cf") || HasExtension(f.FullPath, ".archive")) | ||
| .Select(f => Path.GetDirectoryName(Path.GetFullPath(f.FullPath))) | ||
| .Distinct(StringComparer.OrdinalIgnoreCase); | ||
|
|
||
| if (hashFiles.Count == 0) | ||
| { | ||
| hashFiles.AddRange(contentDirectories | ||
| .Select(dir => Path.Combine(dir, hashFileName)) | ||
| .Where(File.Exists)); | ||
| } | ||
|
|
||
| List<string> buildHashes; | ||
| try | ||
| { | ||
| buildHashes = hashFiles.Select(f => File.ReadAllText(f).Trim()).Distinct().ToList(); | ||
| buildHashes = BuildHistoryHelper.FindBuildHashes(files.Select(f => f.FullPath)); | ||
| } | ||
| catch (Exception e) | ||
| { | ||
| Console.Error.WriteLine($"Error reading {hashFileName}: {e.Message}"); | ||
| Console.Error.WriteLine($"Error reading {BuildHistoryHelper.HashFileName}: {e.Message}"); | ||
| return false; | ||
| } | ||
|
|
||
|
|
@@ -231,7 +210,14 @@ bool PrepareContentDirectoryInputs(List<(string FullPath, string DisplayRoot)> f | |
| } | ||
|
|
||
| var buildHash = buildHashes.Count == 1 ? buildHashes[0] : null; | ||
| var hasContentDirectory = buildHash != null || files.Any(f => HasExtension(f.FullPath, ".cf")); | ||
|
|
||
| if (m_Options.BuildHistoryPath != null && !AddBuildHistoryFiles(buildHash, files)) | ||
| { | ||
| return false; | ||
| } | ||
|
|
||
| var layoutCandidates = files.Where(f => ContentLayoutParser.IsContentLayoutFile(f.FullPath)).ToList(); | ||
| var hasContentDirectory = buildHash != null || BuildHistoryHelper.HasContentFiles(files.Select(f => f.FullPath)); | ||
|
|
||
| if (layoutCandidates.Count == 0) | ||
| { | ||
|
|
@@ -259,7 +245,7 @@ bool PrepareContentDirectoryInputs(List<(string FullPath, string DisplayRoot)> f | |
|
|
||
| // The hash match guarantees the layout describes exactly this build; a stale or | ||
| // unrelated layout would silently produce misleading results. | ||
| selected = layoutCandidates.FirstOrDefault(c => TryReadBuildManifestHash(c.FullPath) == buildHash); | ||
| selected = layoutCandidates.FirstOrDefault(c => BuildHistoryHelper.TryReadBuildManifestHash(c.FullPath) == buildHash); | ||
|
|
||
| if (selected.FullPath == null) | ||
| { | ||
|
|
@@ -282,7 +268,10 @@ bool PrepareContentDirectoryInputs(List<(string FullPath, string DisplayRoot)> f | |
| { | ||
| if (candidate != selected) | ||
| { | ||
| Console.Error.WriteLine($"Ignoring \"{candidate.FullPath}\": its BuildManifestHash does not match the analyzed build."); | ||
| var reason = BuildHistoryHelper.TryReadBuildManifestHash(candidate.FullPath) == buildHash | ||
| ? "it duplicates the selected layout" | ||
| : "its BuildManifestHash does not match the analyzed build"; | ||
| Console.Error.WriteLine($"Ignoring \"{candidate.FullPath}\": {reason}."); | ||
|
Comment on lines
269
to
+274
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Same as above — covered by the read-site normalization in 5ed3eec, so a duplicate with different casing would now be reported as a duplicate. |
||
| files.Remove(candidate); | ||
| } | ||
| } | ||
|
|
@@ -295,34 +284,48 @@ bool PrepareContentDirectoryInputs(List<(string FullPath, string DisplayRoot)> f | |
| return true; | ||
| } | ||
|
|
||
| static bool HasExtension(string path, string extension) | ||
| // --build-history: locate the analyzed build's folder inside the build history and add its | ||
| // ContentLayout.json and build report to the input. Purely additive — the added layout goes | ||
| // through the same candidate validation as a manually passed one. | ||
| bool AddBuildHistoryFiles(string buildHash, List<(string FullPath, string DisplayRoot)> files) | ||
| { | ||
| return string.Equals(Path.GetExtension(path), extension, StringComparison.OrdinalIgnoreCase); | ||
| } | ||
| var historyPath = m_Options.BuildHistoryPath; | ||
|
|
||
| // Reads the top-level BuildManifestHash of a ContentLayout.json without parsing the whole | ||
| // file (layouts of large builds are big; the hash is one of the first properties). Returns | ||
| // null when the value cannot be found or the file is not valid json. | ||
| static string TryReadBuildManifestHash(string path) | ||
| { | ||
| try | ||
| if (!Directory.Exists(historyPath)) | ||
| { | ||
| using var reader = new JsonTextReader(File.OpenText(path)); | ||
| Console.Error.WriteLine($"--build-history path not found: {historyPath}"); | ||
| return false; | ||
| } | ||
|
|
||
| for (int i = 0; i < 64 && reader.Read(); ++i) | ||
| { | ||
| if (reader.TokenType == JsonToken.PropertyName && reader.Depth == 1 && | ||
| "BuildManifestHash".Equals(reader.Value)) | ||
| { | ||
| return reader.ReadAsString(); | ||
| } | ||
| } | ||
| if (buildHash == null) | ||
| { | ||
| Console.Error.WriteLine("--build-history requires a ContentDirectory build in the input: no BuildManifestHash.txt was found to identify the build to match."); | ||
| return false; | ||
| } | ||
| catch (Exception) | ||
|
|
||
| var buildFolder = BuildHistoryHelper.LocateBuildFolder(historyPath, buildHash); | ||
| if (buildFolder == null) | ||
| { | ||
| Console.Error.WriteLine($"No build in \"{historyPath}\" matches the analyzed build (BuildManifestHash {buildHash})."); | ||
| return false; | ||
| } | ||
|
|
||
| return null; | ||
| Console.WriteLine($"Using build history folder \"{buildFolder}\"."); | ||
|
|
||
| // Skip files that are already on the input (e.g. the layout was also passed explicitly). | ||
| var newEntries = new List<(string FullPath, string DisplayRoot)>(); | ||
| foreach (var file in BuildHistoryHelper.CollectBuildFiles(buildFolder)) | ||
| { | ||
| var alreadyIncluded = files.Any(f => string.Equals( | ||
| Path.GetFullPath(f.FullPath), Path.GetFullPath(file), StringComparison.OrdinalIgnoreCase)); | ||
|
|
||
| if (!alreadyIncluded) | ||
| newEntries.Add((file, buildFolder)); | ||
| } | ||
|
|
||
| files.InsertRange(0, newEntries); | ||
|
|
||
| return true; | ||
| } | ||
|
|
||
| // Expands the input paths into the concrete files to analyze. Each result pairs the file with the | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,138 @@ | ||
| using System; | ||
| using System.Collections.Generic; | ||
| using System.IO; | ||
| using System.Linq; | ||
| using Newtonsoft.Json; | ||
| using UnityDataTools.Models; | ||
|
|
||
| namespace UnityDataTools.Analyzer.Util; | ||
|
|
||
| // Identifies the ContentDirectory build present in the analyze input and locates its folder | ||
| // inside a build history (Library/BuildHistory, one folder per build). The folder of a build is | ||
| // found by matching the BuildManifestHash of its ContentLayout.json against the build's | ||
| // BuildManifestHash.txt. Pure file lookup; no knowledge of the parsers or the database. | ||
| public static class BuildHistoryHelper | ||
| { | ||
| public const string HashFileName = "BuildManifestHash.txt"; | ||
| public const string LayoutFileName = "ContentLayout.json"; | ||
| public const string SummaryFileName = "BuildReportSummary.json"; | ||
|
|
||
| // Reads the BuildManifestHash.txt values identifying the ContentDirectory build(s) in the | ||
| // input files: taken from the input itself when present, otherwise picked up next to the | ||
| // .cf/.archive content files (the hash file is not always on the input, e.g. when specific | ||
| // files are passed). More than one distinct hash means more than one build. | ||
| public static List<string> FindBuildHashes(IEnumerable<string> files) | ||
| { | ||
| var fileList = files.ToList(); | ||
|
|
||
| var hashFiles = fileList | ||
| .Where(f => string.Equals(Path.GetFileName(f), HashFileName, StringComparison.OrdinalIgnoreCase)) | ||
| .ToList(); | ||
|
|
||
| if (hashFiles.Count == 0) | ||
| { | ||
| var contentDirectories = fileList | ||
| .Where(f => HasExtension(f, ".cf") || HasExtension(f, ".archive")) | ||
| .Select(f => Path.GetDirectoryName(Path.GetFullPath(f))) | ||
| .Distinct(StringComparer.OrdinalIgnoreCase); | ||
|
|
||
| hashFiles.AddRange(contentDirectories | ||
| .Select(dir => Path.Combine(dir, HashFileName)) | ||
| .Where(File.Exists)); | ||
| } | ||
|
|
||
| // Hashes are lowercase hex; normalizing here (and in TryReadBuildManifestHash) keeps all | ||
| // hash comparisons simple, exact matches. | ||
| return hashFiles.Select(f => File.ReadAllText(f).Trim().ToLowerInvariant()).Distinct().ToList(); | ||
| } | ||
|
|
||
| // Fallback signal used when no BuildManifestHash.txt is available: a .cf file marks | ||
| // ContentDirectory output. This is only a rough sanity check — the reverse does not hold, as | ||
| // content files can be built without the extension or sit inside archives. | ||
| public static bool HasContentFiles(IEnumerable<string> files) | ||
| { | ||
| return files.Any(f => HasExtension(f, ".cf")); | ||
| } | ||
|
|
||
| // Searches the history root and its direct child folders for the ContentLayout.json whose | ||
| // BuildManifestHash matches the analyzed build. A build history is flat, so there is no | ||
| // recursion — which also keeps the scan bounded if a large unrelated folder is passed. When | ||
| // several folders match (rebuilds of identical content), the most recent build wins. | ||
| // Returns the containing folder, or null when nothing matches. | ||
| public static string LocateBuildFolder(string historyRoot, string buildHash) | ||
| { | ||
| var matches = Directory.EnumerateDirectories(historyRoot) | ||
| .Prepend(historyRoot) | ||
| .Where(dir => File.Exists(Path.Combine(dir, LayoutFileName))) | ||
| .Where(dir => TryReadBuildManifestHash(Path.Combine(dir, LayoutFileName)) == buildHash); | ||
|
|
||
| string selected = null; | ||
| var selectedTime = default(DateTime); | ||
|
Comment on lines
+62
to
+70
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Addressed in 5ed3eec by normalizing to lowercase where the hashes are read (FindBuildHashes and TryReadBuildManifestHash) instead of at each comparison — both sides of this == are now guaranteed lowercase. |
||
| foreach (var folder in matches) | ||
| { | ||
| var startTime = TryReadBuildStartTime(folder); | ||
| if (selected == null || startTime > selectedTime) | ||
| { | ||
| selected = folder; | ||
| selectedTime = startTime; | ||
| } | ||
| } | ||
|
|
||
| return selected; | ||
| } | ||
|
|
||
| // Reads the start time of the build from the BuildReportSummary.json of a build history | ||
| // folder. The file is expected to always be present; returns a zero timestamp when it is | ||
| // missing or unreadable. | ||
| public static DateTime TryReadBuildStartTime(string buildFolder) | ||
| { | ||
| try | ||
| { | ||
| var json = File.ReadAllText(Path.Combine(buildFolder, SummaryFileName)); | ||
| var summary = JsonConvert.DeserializeObject<BuildReportSummary>(json); | ||
| return summary?.BuildStartedAt ?? default; | ||
| } | ||
| catch (Exception) | ||
| { | ||
| return default; | ||
| } | ||
| } | ||
|
|
||
| // The files of a build history folder that analyze imports: the layout and the build report. | ||
| public static List<string> CollectBuildFiles(string buildFolder) | ||
| { | ||
| var files = new List<string> { Path.Combine(buildFolder, LayoutFileName) }; | ||
| files.AddRange(Directory.EnumerateFiles(buildFolder, "*.buildreport")); | ||
| return files; | ||
| } | ||
|
|
||
| // Reads the top-level BuildManifestHash of a ContentLayout.json without parsing the whole | ||
| // file (layouts of large builds are big; the hash is one of the first properties). Returns | ||
| // null when the value cannot be found or the file is not valid json. | ||
| public static string TryReadBuildManifestHash(string contentLayoutPath) | ||
| { | ||
| try | ||
| { | ||
| using var reader = new JsonTextReader(File.OpenText(contentLayoutPath)); | ||
|
|
||
| for (int i = 0; i < 64 && reader.Read(); ++i) | ||
| { | ||
| if (reader.TokenType == JsonToken.PropertyName && reader.Depth == 1 && | ||
| "BuildManifestHash".Equals(reader.Value)) | ||
| { | ||
| return reader.ReadAsString()?.ToLowerInvariant(); | ||
| } | ||
| } | ||
| } | ||
| catch (Exception) | ||
| { | ||
| } | ||
|
|
||
| return null; | ||
| } | ||
|
|
||
| static bool HasExtension(string path, string extension) | ||
| { | ||
| return string.Equals(Path.GetExtension(path), extension, StringComparison.OrdinalIgnoreCase); | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Both sides of this comparison are lowercased at the read sites in BuildHistoryHelper as of 5ed3eec (BuildManifestHash.txt in FindBuildHashes, ContentLayout.json in TryReadBuildManifestHash), so the selection here is effectively case-insensitive without changing the comparison.