Browse Source

Accept a commit-ish as a decompdiff side

Comparing two commits meant preparing a worktree for each by hand before the
tool could be called, which is most of the work of running it and easy to get
wrong: a checkout carrying a stale Release build is reused silently, and the
timestamp in the header line was the only thing that said so.

--old and --new now also take anything git can resolve to a commit, checked
out into a worktree under ~/.cache/decompdiff keyed by that commit. The
worktrees are kept because the Release build inside one is what a rerun would
otherwise repeat: a second run of the same pair drops from minutes to seconds.
Paths keep priority over refs, so an existing directory never changes meaning.

Assisted-by: Claude:claude-opus-5[1m]:Claude Code
pull/4078/head
Siegfried Pammer 3 weeks ago
parent
commit
f440f28301
  1. 20
      TestTools/README.md
  2. 87
      TestTools/decompdiff.cs

20
TestTools/README.md

@ -64,13 +64,27 @@ round-trip tests check; this checks readability. Exit code 1 means the new side @@ -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 <dir> 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/<repo>/<commit>`, 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`.

87
TestTools/decompdiff.cs

@ -24,11 +24,15 @@ @@ -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 <ILSpy-checkout|Decompiler.dll> --new <ILSpy-checkout|Decompiler.dll>
// usage: dotnet run decompdiff.cs -- --old <commit-ish|ILSpy-checkout|Decompiler.dll> --new <...>
// [-o <report-dir>] [--build] [--refs <dir>]... <dll|dir>...
//
// - 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/<repo>/<commit>, 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 <report-dir>/{old,new}/...; inspect with
// `git diff --no-index <report-dir>/old <report-dir>/new`, or open the generated
@ -99,7 +103,7 @@ for (int i = 0; i < args.Length; i++) @@ -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 <ILSpy-checkout|Decompiler.dll> --new <...> [-o report-dir] [--build] [--refs <dir>]... <dll|dir>...");
Console.Error.WriteLine("usage: decompdiff --old <commit-ish|ILSpy-checkout|Decompiler.dll> --new <...> [-o report-dir] [--build] [--refs <dir>]... <dll|dir>...");
return 1;
}
reportDir ??= "decompdiff-report";
@ -114,8 +118,19 @@ if (Directory.Exists(reportDir)) @@ -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 @@ -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 @@ -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<string, TypeResult>? DecompileAssembly(string dllPath)

Loading…
Cancel
Save