diff --git a/TestTools/README.md b/TestTools/README.md index 379b1334b..e60639f6c 100644 --- a/TestTools/README.md +++ b/TestTools/README.md @@ -64,13 +64,27 @@ round-trip tests check; this checks readability. Exit code 1 means the new side side did not. ```pwsh +dotnet run decompdiff.cs -- --old master --new fix/my-branch -o report ~/.cache/nugetfuzz dotnet run decompdiff.cs -- --old ../../ILSpy-master --new . -o report ~/.cache/nugetfuzz dotnet run decompdiff.cs -- --old v9.1.dll --new v11.dll --refs corpus.dll ``` -An `--old`/`--new` argument is either a path to `ICSharpCode.Decompiler.dll` or an ILSpy checkout, -which is restored and built in Release on demand. **Watch the timestamp in the header line**: an -existing Release build is reused as-is; pass `--build` to force a rebuild. +An `--old`/`--new` argument is a path to `ICSharpCode.Decompiler.dll`, an ILSpy checkout, or a +commit-ish. A checkout is restored and built in Release on demand. **Watch the timestamp in the +header line**: an existing Release build is reused as-is; pass `--build` to force a rebuild. + +A commit-ish (branch, tag, sha, `FETCH_HEAD`) is resolved against the repository the tool is run +from and checked out into a worktree under `~/.cache/decompdiff//`, so diffing two +commits needs no checkouts prepared by hand. Paths win over refs, so a branch that shares its name +with a directory has to be spelled as a path. The worktrees are kept: a rerun reuses the Release +build already in one, which is what dominates the runtime. They live outside the repository and +`git worktree remove` (or deleting the cache directory) is enough to clean them up. To diff a pull +request, fetch it first: + +```pwsh +git fetch origin pull/4071/head +dotnet run decompdiff.cs -- --old origin/master --new FETCH_HEAD -o report ~/.cache/nugetfuzz +``` The report directory gets `summary.md`, a self-contained `index.html` with inline diffs, and the changed types dumped under `old/` and `new/` for `git diff --no-index report/old report/new`. diff --git a/TestTools/decompdiff.cs b/TestTools/decompdiff.cs index d32221bce..a653d64b3 100644 --- a/TestTools/decompdiff.cs +++ b/TestTools/decompdiff.cs @@ -24,11 +24,15 @@ // changes across real-world code. The textual complement to the Windows // round-trip tests, which verify correctness but not output quality. // -// usage: dotnet run decompdiff.cs -- --old --new +// usage: dotnet run decompdiff.cs -- --old --new <...> // [-o ] [--build] [--refs ]... ... // // - checkout args are built on demand (Release; restore keeps packages.lock.json // whole via -p:RestoreEnablePackagePruning=false); pass --build to force rebuild. +// - a commit-ish (branch, tag, sha, FETCH_HEAD) is resolved against the repository +// the tool is run from and checked out into a worktree under +// ~/.cache/decompdiff//, kept and reused so a rerun keeps the +// Release build it already contains. // - corpus dirs are scanned recursively for *.dll (e.g. ~/.cache/nugetfuzz). // - changed/errored types are written to /{old,new}/...; inspect with // `git diff --no-index /old /new`, or open the generated @@ -99,7 +103,7 @@ for (int i = 0; i < args.Length; i++) } if (oldSpec == null || newSpec == null || corpus.Count == 0) { - Console.Error.WriteLine("usage: decompdiff --old --new <...> [-o report-dir] [--build] [--refs ]... ..."); + Console.Error.WriteLine("usage: decompdiff --old --new <...> [-o report-dir] [--build] [--refs ]... ..."); return 1; } reportDir ??= "decompdiff-report"; @@ -114,8 +118,19 @@ if (Directory.Exists(reportDir)) } Directory.CreateDirectory(reportDir); -var oldSide = Side.Create("old", oldSpec, forceBuild); -var newSide = Side.Create("new", newSpec, forceBuild); +Side oldSide, newSide; +try +{ + oldSide = Side.Create("old", oldSpec, forceBuild); + newSide = Side.Create("new", newSpec, forceBuild); +} +catch (ArgumentException ex) +{ + // A mistyped branch name is the easiest way to get here, and its message says more + // than the stack trace does. + Console.Error.WriteLine(ex.Message); + return 1; +} Console.WriteLine($"old: {oldSide.Description}"); Console.WriteLine($"new: {newSide.Description}"); @@ -856,9 +871,19 @@ class Side // The dll timestamp exposes stale pre-existing builds; --build forces a fresh one. description = $"{checkout} ({GitDescribe(checkout)}, dll of {File.GetLastWriteTime(dllPath):yyyy-MM-dd HH:mm})"; } + else if (TryResolveCommit(spec, out var commit, out var repoRoot)) + { + // A git ref, so a PR or a tag can be diffed without preparing checkouts by hand. + // The worktree is keyed by commit and kept: reusing it reuses the Release build + // already sitting in its bin/, which is what dominates the runtime of a rerun. + var checkout = EnsureWorktree(repoRoot, commit); + dllPath = BuildCheckout(checkout, forceBuild); + description = $"{spec} ({commit[..9]}, dll of {File.GetLastWriteTime(dllPath):yyyy-MM-dd HH:mm})"; + } else { - throw new ArgumentException($"--{name} {spec}: no such file or directory"); + throw new ArgumentException( + $"--{name} {spec}: not a file, a directory, or a commit-ish in the repository at {Environment.CurrentDirectory}"); } var alc = new DecompilerLoadContext(name, dllPath); return new Side(alc.LoadFromAssemblyPath(dllPath), description); @@ -897,26 +922,68 @@ class Side throw new InvalidOperationException($"{exe} {arguments} failed:\n{output}"); } - static string GitDescribe(string checkout) + // Resolves a commit-ish against the repository the tool is run from. Files and directories + // win over refs, so a branch sharing a name with a directory still needs the path spelled out. + static bool TryResolveCommit(string spec, out string commit, out string repoRoot) + { + commit = ""; + repoRoot = ""; + var root = Git(Environment.CurrentDirectory, "rev-parse", "--show-toplevel"); + if (root == null) + return false; + var resolved = Git(root, "rev-parse", "--verify", "--quiet", spec + "^{commit}"); + if (string.IsNullOrEmpty(resolved)) + return false; + commit = resolved; + repoRoot = root; + return true; + } + + // Worktrees live outside the repository, so they never show up in its status or get + // swept up by a clean; `git worktree list` still shows them, and they are safe to delete. + static string EnsureWorktree(string repoRoot, string commit) + { + var dir = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + ".cache", "decompdiff", Path.GetFileName(repoRoot), commit); + if (Directory.Exists(dir)) + return dir; + Directory.CreateDirectory(Path.GetDirectoryName(dir)!); + Console.WriteLine($" $ git worktree add --detach {dir} {commit[..9]}"); + if (Git(repoRoot, "worktree", "add", "--detach", dir, commit) == null) + throw new InvalidOperationException($"git worktree add failed for {commit}"); + return dir; + } + + // Runs git and returns its trimmed stdout, or null if it could not be run or failed. + static string? Git(string workingDirectory, params string[] arguments) { try { - var psi = new ProcessStartInfo("git", "describe --always --dirty --exclude *") { - WorkingDirectory = checkout, + var psi = new ProcessStartInfo("git") { + WorkingDirectory = workingDirectory, RedirectStandardOutput = true, RedirectStandardError = true, }; + foreach (var argument in arguments) + psi.ArgumentList.Add(argument); using var p = Process.Start(psi)!; var output = p.StandardOutput.ReadToEnd().Trim(); p.WaitForExit(); - return p.ExitCode == 0 && output.Length > 0 ? output : "unknown"; + return p.ExitCode == 0 ? output : null; } catch { - return "unknown"; + return null; } } + static string GitDescribe(string checkout) + { + var output = Git(checkout, "describe", "--always", "--dirty", "--exclude", "*"); + return string.IsNullOrEmpty(output) ? "unknown" : output; + } + // Decompiles every top-level type; null when the assembly itself cannot be // opened (not managed, type system init failed) - see LastAssemblyError. public Dictionary? DecompileAssembly(string dllPath)