diff --git a/CHANGELOG.md b/CHANGELOG.md
index ba05e24..dcf11c8 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,15 @@
# Changelog
+## 0.9.3 — 2026-10-19
+
+- `ado_trace`: "is the change in this branch / deployment" asks the history of the target around the day the change
+ was merged (one cheap call) instead of a diff between the commits, which took 20-30 seconds each in a large
+ repository; the diff stays as the fallback when the date is unknown.
+- Code moves forward through the environments of the map (dev -> staging -> master): a change merged into a later
+ one is not looked for in an earlier one.
+- "not yet" leaves out a change whose work is already there through another PR from the same source branch (the same
+ work merged into each environment by its own PR).
+
## 0.9.2 — 2026-10-19
Fixes and additions from testing 0.9 on a real project.
diff --git a/src/AdoLens/AdoLens.csproj b/src/AdoLens/AdoLens.csproj
index 1a131a9..9d28833 100644
--- a/src/AdoLens/AdoLens.csproj
+++ b/src/AdoLens/AdoLens.csproj
@@ -23,7 +23,7 @@
ADO Lens for Azure DevOps
Azure DevOps (work items, boards, wiki, Git and TFVC, pipelines) served to AI assistants over MCP
Copyright © 2026 WillowDev
- 0.9.2
+ 0.9.3
diff --git a/src/AdoLens/Code.cs b/src/AdoLens/Code.cs
index fb48a44..4f37bda 100644
--- a/src/AdoLens/Code.cs
+++ b/src/AdoLens/Code.cs
@@ -162,8 +162,23 @@ public async Task PullRequestAsync(string? repoId, int id, bool with
}
/// Whether is in the history of (a commit or branch).
- public async Task ContainsAsync(string repoId, string commit, string target, bool targetIsBranch, CancellationToken ct)
+ public async Task ContainsAsync(string repoId, string commit, string target, bool targetIsBranch, CancellationToken ct, DateTime? committed = null)
{
+ // With the commit's date: the history of the target around that day holds the commit exactly when the target
+ // contains it. One cheap call, where a diff between far-apart commits takes seconds in a large repository.
+ if (committed is { } when)
+ {
+ try
+ {
+ var near = await client.GetListAsync($"_apis/git/repositories/{repoId}/commits?searchCriteria.itemVersion.version={Uri.EscapeDataString(target)}" +
+ $"&searchCriteria.itemVersion.versionType={(targetIsBranch ? "branch" : "commit")}" +
+ $"&searchCriteria.fromDate={Uri.EscapeDataString(when.AddDays(-1).ToString("o", CultureInfo.InvariantCulture))}" +
+ $"&searchCriteria.toDate={Uri.EscapeDataString(when.AddDays(1).ToString("o", CultureInfo.InvariantCulture))}&searchCriteria.$top=1000", ct);
+ if (near.Any(c => string.Equals(Json.Str(c, "commitId"), commit, StringComparison.OrdinalIgnoreCase))) return true;
+ if (near.Count < 1000) return false;
+ }
+ catch (AdoApiException) { }
+ }
try
{
var diff = await client.GetAsync($"_apis/git/repositories/{repoId}/diffs/commits?baseVersion={commit}&baseVersionType=commit" +
diff --git a/src/AdoLens/Trace.cs b/src/AdoLens/Trace.cs
index b9db80e..e943865 100644
--- a/src/AdoLens/Trace.cs
+++ b/src/AdoLens/Trace.cs
@@ -287,15 +287,19 @@ string Label(string? branch, string def, string stage) =>
// with several PRs and many release stages).
var containsCache = new System.Collections.Concurrent.ConcurrentDictionary<(string, string, bool), Task>();
using var gate = new SemaphoreSlim(ParallelChecks);
- Task Contains(string repo, string commit, string target, bool isBranch) =>
- containsCache.GetOrAdd((commit, target, isBranch), _ => Gated(() => code.ContainsAsync(repo, commit, target, isBranch, dt)));
+ Task Contains(string repo, string commit, string target, bool isBranch, DateTime? committed) =>
+ containsCache.GetOrAdd((commit, target, isBranch), _ => Gated(() => code.ContainsAsync(repo, commit, target, isBranch, dt, committed)));
+ // Code moves forward through the environments (dev -> staging -> master): a change merged into a later one is
+ // not in an earlier one, so that is not asked.
+ bool Later(string? changeTarget, string? branch) =>
+ changeTarget is not null && branch is not null && envOrder.TryGetValue(changeTarget, out var a) && envOrder.TryGetValue(branch, out var b) && a > b;
async Task Gated(Func> check)
{
await gate.WaitAsync(dt);
try { return await check(); }
finally { gate.Release(); }
}
- async Task<(List In, List NotYet, bool Known)> GitStatus(string repo, string target, bool isBranch, DateTime? deployedAt)
+ async Task<(List In, List NotYet, bool Known)> GitStatus(string repo, string target, bool isBranch, DateTime? deployedAt, string? targetBranch = null)
{
var git = changes.Select(x => x.Change).Where(x => x.Kind != "changeset" && string.Equals(x.RepoId, repo, StringComparison.OrdinalIgnoreCase)).ToList();
// Only the newest change of each source -> target pair is asked about: the older ones of the pair are in
@@ -308,8 +312,9 @@ string Label(string? branch, string def, string stage) =>
: isBranch && g.Kind == "pr" && string.Equals(g.TargetBranch, target, StringComparison.OrdinalIgnoreCase) ? true
// A deployment that finished before a change was merged cannot contain it: no need to ask.
: deployedAt is { } at && g.Date is { } merged && at < merged ? false
+ : Later(g.TargetBranch, isBranch ? target : targetBranch) ? false
: !newest.Contains(g) ? (bool?)null
- : await Contains(repo, commit, target, isBranch)));
+ : await Contains(repo, commit, target, isBranch, g.Date)));
// The older changes of a pair follow the answer for its newest one.
for (var i = 0; i < git.Count; i++)
if (answers[i] is null && git[i].LandedCommit is not null
@@ -317,8 +322,10 @@ string Label(string? branch, string def, string stage) =>
answers[i] = answers[n.k];
var inside = git.Where((g, i) => answers[i] == true).ToList();
var known = answers.Any(a => a is not null);
+ // Not yet: unless a change from the same source branch is in and no older than a day before it (the same work
+ // merged into each environment by its own PR).
var notYet = git.Where(g => !inside.Contains(g) && !IsPromotion(g)
- && !inside.Any(i => i.Branch is not null && i.Branch == g.Branch && i.Date >= g.Date)).ToList();
+ && !inside.Any(i => i.Branch is not null && i.Branch == g.Branch && i.Date >= g.Date?.AddDays(-1))).ToList();
return (inside, notYet, known);
}
static string Names(IEnumerable list) =>
@@ -351,7 +358,7 @@ static string Names(IEnumerable list) =>
{
stageTasks.Add(Task.Run(async () =>
{
- var (inside, notYet, _) = await GitStatus(repo, deployedCommit, false, d.When);
+ var (inside, notYet, _) = await GitStatus(repo, deployedCommit, false, d.When, Code.Short(d.Branch));
if (inside.Count > 0) anyIn = true;
return (string?)(label + (inside.Count > 0
? $"YES - deployed {deployed}: {Names(inside.Where(x => !IsPromotion(x)).DefaultIfEmpty(inside[0]))}" + (notYet.Count > 0 ? $"; not yet: {Names(notYet)}" : "")