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)